editor/AdvancedMappingTools/Architecture/RoofStudioCovering.cs

Editor-side geometry generator for the Roof Studio architecture tool. Builds tile, shingle and standing-seam covering rows on each roof slope, a ridge cap for gable and hip roofs, and scattered plant cushions for green roofs, plus the settings and property-hiding logic for those options.

namespace AdvancedMappingTools;

public enum RoofCovering
{
	None,
	[Title( "Clay Tiles" )] Tiles,
	Shingles,
	[Title( "Standing Seam" )] StandingSeam
}

public sealed partial class RoofStudioSettings
{
	[Property, Group( "Covering" ), Title( "Covering" ), WideMode, Description( "What lies on a pitched roof: rows of tiles or shingles, or metal sheets with raised seams." )]
	public RoofCovering Covering { get; set; } = RoofCovering.Tiles;

	[Property, Group( "Covering" ), Title( "Course Length" ), Range( 4, 64, slider: false ), WideMode, Description( "Length of one row of tiles or shingles up the slope, or the distance between two seams." )]
	public float CourseLength { get; set; } = 12;

	[Property, Group( "Covering" ), Title( "Covering Thickness" ), Range( 0.5f, 8, slider: false ), WideMode]
	public float CoveringThickness { get; set; } = 1.5f;

	[Property, Group( "Covering" ), Title( "Ridge Cap" ), WideMode, Description( "A capping along the ridge of a gable or hip roof." )]
	public bool RidgeCap { get; set; } = true;

	[Property, Group( "Covering" ), Title( "Ridge Cap Width" ), Range( 2, 32, slider: false ), WideMode]
	public float RidgeCapWidth { get; set; } = 8;

	[Property, Group( "Planting" ), Title( "Plants" ), WideMode, Description( "Low cushions of sedum and grass on a green roof." )]
	public bool Plants { get; set; } = true;

	[Property, Group( "Planting" ), Title( "Plant Density" ), Range( 0, 1 ), WideMode]
	public float PlantDensity { get; set; } = 0.6f;

	[Property, Group( "Planting" ), Range( 0, 9999, slider: false ), WideMode, Description( "Seed for where the plants grow." )]
	public int PlantSeed { get; set; } = 1;
}

/// <summary>
/// The covering of Roof Studio: rows of tiles or shingles, or standing seams, lying on every
/// slope the roof toolkit builds, a ridge cap, and the planting of a green roof. Every row stands
/// straight up on its own strip of the slope, so rows only touch and never cut into each other.
/// </summary>
internal static partial class RoofStudioCovering
{
	/// <summary>
	/// Options that do nothing for the chosen style.
	/// </summary>
	internal static void Hide( HashSet<string> hidden, RoofStudioSettings s )
	{
		var covered = !s.IsFlat && !s.Hollow;
		if ( !covered )
			hidden.UnionWith( [nameof( s.Covering ), nameof( s.CourseLength ), nameof( s.CoveringThickness ), nameof( s.RidgeCap ), nameof( s.RidgeCapWidth )] );
		else
		{
			if ( s.Covering == RoofCovering.None )
				hidden.UnionWith( [nameof( s.CourseLength ), nameof( s.CoveringThickness )] );
			if ( s.Style is not (StudioRoofStyle.Gable or StudioRoofStyle.Hip) )
				hidden.UnionWith( [nameof( s.RidgeCap ), nameof( s.RidgeCapWidth )] );
			else if ( !s.RidgeCap )
				hidden.Add( nameof( s.RidgeCapWidth ) );
			if ( s.Covering != RoofCovering.None )
				hidden.Remove( nameof( s.FinishMaterial ) );
		}

		if ( s.Style != StudioRoofStyle.Green )
			hidden.UnionWith( [nameof( s.Plants ), nameof( s.PlantDensity ), nameof( s.PlantSeed )] );
		else if ( !s.Plants )
			hidden.UnionWith( [nameof( s.PlantDensity ), nameof( s.PlantSeed )] );
		HideWindows( hidden, s );
	}

	/// <summary>
	/// One slope: its lower edge from A0 to B0 and its upper edge from A1 to B1. A row at a share f
	/// of the way up runs from A(f) to B(f).
	/// </summary>
	readonly record struct Slope( Vector3 A0, Vector3 B0, Vector3 A1, Vector3 B1, bool Pointed )
	{
		public Vector3 A( float f ) => Vector3.Lerp( A0, A1, f );
		public Vector3 B( float f ) => Vector3.Lerp( B0, B1, f );
		public float Length => ((A0 + B0) * 0.5f - (A1 + B1) * 0.5f).Length;
		/// <summary>How far the slope reaches seen from above, from its lower to its upper edge.</summary>
		public float Run => ((A0 + B0) * 0.5f - (A1 + B1) * 0.5f).WithZ( 0 ).Length;
	}

	/// <summary>
	/// Adds covering and ridge cap for a pitched roof built by <see cref="RoofEditor.BuildRoof"/>
	/// over x0..x1, y0..y1 with its eaves at <paramref name="edge"/> and its ridge <paramref name="rise"/> above.
	/// </summary>
	internal static void Build( ArchitectureStudioPlan p, RoofStudioSettings s, float x0, float x1, float y0, float y1, float edge, float rise )
	{
		if ( s.IsFlat || s.Hollow || s.Covering == RoofCovering.None && !s.RidgeCap ) return;

		var openings = Openings( s, x0, x1, y0, y1, edge, rise );
		var (slopes, turned) = Layout( s, x0, x1, y0, y1, edge, rise );
		if ( turned ) (x0, x1, y0, y1) = (y0, y1, -x1, -x0);
		var mid = (y0 + y1) * 0.5f;
		var ridge = edge + rise;
		var hip = (y1 - y0) * 0.5f;

		var thick = Math.Clamp( s.CoveringThickness, 0.5f, 8 );
		var course = Math.Clamp( s.CourseLength, 4, 64 );
		var capHalf = s.RidgeCap && s.Style is StudioRoofStyle.Gable or StudioRoofStyle.Hip ? Math.Clamp( s.RidgeCapWidth, 2, 32 ) * 0.5f : 0.0f;

		void Turn( MeshKit kit )
		{
			if ( turned ) kit.Frame = kit.Frame.ToWorld( new Transform( Vector3.Zero, Rotation.FromYaw( 90.0f ) ) );
		}

		if ( s.Covering != RoofCovering.None )
			p.Add( "covering", s.Covering == RoofCovering.StandingSeam ? "Metal Sheets" : "Roof Covering", p.Finish, kit =>
			{
				Turn( kit );
				for ( var i = 0; i < slopes.Length; i++ )
				{
					var slope = slopes[i];
					var mine = openings.Where( o => o.Slope == i ).ToList();
					// The rows stop below the ridge cap, the cap covers the rest.
					var top = capHalf > 0 ? MathF.Max( 0.0f, 1.0f - (capHalf + 0.25f) / MathF.Max( slope.Run, 0.01f ) ) : 1.0f;
					if ( slope.Pointed ) top = MathF.Min( top, 1.0f - course / slope.Length );
					if ( top <= 0.0f ) continue;

					if ( s.Covering == RoofCovering.StandingSeam )
						Sheets( kit, slope, mine, top, thick, course );
					else
						Rows( kit, slope, mine, top, thick, s.Covering == RoofCovering.Shingles ? course * 0.6f : course, s.Covering == RoofCovering.Tiles );
				}
			} );

		if ( capHalf > 0 && s.Style is StudioRoofStyle.Gable or StudioRoofStyle.Hip )
		{
			var a = s.Style == StudioRoofStyle.Hip ? x0 + hip : x0;
			var b = s.Style == StudioRoofStyle.Hip ? x1 - hip : x1;
			if ( b - a > 1.0f )
				p.Add( "ridge_cap", "Ridge Cap", p.Detail, kit =>
				{
					Turn( kit );
					// Two boards, one on each slope, meeting upright over the ridge.
					var lift = s.Covering == RoofCovering.None ? 0.0f : thick * 1.6f;
					var drop = rise / (y1 - y0) * 2.0f * capHalf;
					// They stand on the roof itself, in the band the covering leaves free.
					var height = lift + thick * 1.5f;
					foreach ( var side in new[] { -1.0f, 1.0f } )
					{
						var outer = mid + side * capHalf;
						var low = ridge - drop;
						Prism( kit, new( a, outer, low ), new( b, outer, low ), new( b, mid, ridge ), new( a, mid, ridge ), height, height );
					}
				} );
		}
	}

	/// <summary>
	/// The slopes of a pitched roof over x0..x1, y0..y1, and whether a deep hip roof is turned by 90 degrees.
	/// </summary>
	static (Slope[] Slopes, bool Turned) Layout( RoofStudioSettings s, float x0, float x1, float y0, float y1, float edge, float rise )
	{
		// The hip roof turns a deep plan by 90 degrees, as the roof toolkit does.
		var turned = s.Style == StudioRoofStyle.Hip && y1 - y0 > x1 - x0;
		if ( turned ) (x0, x1, y0, y1) = (y0, y1, -x1, -x0);

		var mid = (y0 + y1) * 0.5f;
		var ridge = edge + rise;
		var hip = (y1 - y0) * 0.5f;
		Slope[] slopes = s.Style switch
		{
			StudioRoofStyle.Gable =>
			[
				new Slope( new( x0, y0, edge ), new( x1, y0, edge ), new( x0, mid, ridge ), new( x1, mid, ridge ), false ),
				new Slope( new( x1, y1, edge ), new( x0, y1, edge ), new( x1, mid, ridge ), new( x0, mid, ridge ), false ),
			],
			StudioRoofStyle.Shed => [new Slope( new( x0, y0, edge ), new( x1, y0, edge ), new( x0, y1, ridge ), new( x1, y1, ridge ), false )],
			StudioRoofStyle.Butterfly =>
			[
				new Slope( new( x0, mid, edge ), new( x1, mid, edge ), new( x0, y0, ridge ), new( x1, y0, ridge ), false ),
				new Slope( new( x1, mid, edge ), new( x0, mid, edge ), new( x1, y1, ridge ), new( x0, y1, ridge ), false ),
			],
			StudioRoofStyle.Hip =>
			[
				new Slope( new( x0, y0, edge ), new( x1, y0, edge ), new( x0 + hip, mid, ridge ), new( x1 - hip, mid, ridge ), false ),
				new Slope( new( x1, y1, edge ), new( x0, y1, edge ), new( x1 - hip, mid, ridge ), new( x0 + hip, mid, ridge ), false ),
				new Slope( new( x0, y1, edge ), new( x0, y0, edge ), new( x0 + hip, mid, ridge ), new( x0 + hip, mid, ridge ), true ),
				new Slope( new( x1, y0, edge ), new( x1, y1, edge ), new( x1 - hip, mid, ridge ), new( x1 - hip, mid, ridge ), true ),
			],
			_ => new Slope[0]
		};
		return (slopes, turned);
	}

	/// <summary>
	/// Rows of tiles or shingles: each row is a strip of the slope, thicker at its lower edge so it
	/// seems to lie over the row below. Clay tiles are split into single tiles across the slope.
	/// </summary>
	static void Rows( MeshKit kit, Slope slope, IReadOnlyList<RoofStudioOpening> openings, float top, float thick, float course, bool tiles )
	{
		var rows = Math.Max( 1, (int)MathF.Ceiling( slope.Length * top / course ) );
		var gap = MathF.Min( 0.3f / MathF.Max( slope.Length, 1.0f ), 0.1f / rows );
		for ( var r = 0; r < rows; r++ )
		{
			var f0 = top * r / rows;
			var f1 = top * (r + 1) / rows - gap;
			var across = MathF.Min( (slope.B( f0 ) - slope.A( f0 )).Length, (slope.B( f1 ) - slope.A( f1 )).Length );
			// Tiles: pieces about as wide as a course is long, every other row shifted by half.
			var pieces = tiles ? Math.Max( 1, (int)MathF.Round( across / (course * 0.9f) ) ) : 1;
			var shift = tiles && r % 2 == 1 ? 0.5f : 0.0f;
			var cuts = new List<float> { 0.0f };
			for ( var k = 1; k < pieces + (shift > 0 ? 1 : 0); k++ )
			{
				var u = (k - shift) / pieces;
				if ( u > 0.02f && u < 0.98f ) cuts.Add( u );
			}
			cuts.Add( 1.0f );

			// Roof windows: the row stops at the flashing and goes on behind it.
			var free = Free( slope, openings, f0, f1 );
			foreach ( var (a, b) in free )
				cuts.AddRange( new[] { a, b }.Where( c => c > 0.0f && c < 1.0f ) );
			cuts = [.. cuts.Distinct().OrderBy( c => c )];

			for ( var k = 0; k + 1 < cuts.Count; k++ )
			{
				var middle = (cuts[k] + cuts[k + 1]) * 0.5f;
				if ( free.Any( r => middle > r.U0 && middle < r.U1 ) ) continue;
				var u0 = cuts[k] + (k > 0 ? 0.004f : 0.0f);
				var u1 = cuts[k + 1] - (k + 2 < cuts.Count ? 0.004f : 0.0f);
				if ( u1 <= u0 ) continue;
				Vector3 P( float f, float u ) => Vector3.Lerp( slope.A( f ), slope.B( f ), u );
				var p0 = P( f0, u0 ); var p1 = P( f0, u1 ); var p2 = P( f1, u1 ); var p3 = P( f1, u0 );
				if ( (p1 - p0).Length < 0.05f || (p2 - p3).Length < 0.05f ) continue;
				Prism( kit, p0, p1, p2, p3, thick * 1.6f, thick * 0.6f );
			}
		}
	}

	/// <summary>
	/// Metal sheets: the slope in one piece per sheet width, with a raised seam between two sheets.
	/// </summary>
	static void Sheets( MeshKit kit, Slope slope, IReadOnlyList<RoofStudioOpening> openings, float top, float thick, float spacing )
	{
		var across = (slope.B0 - slope.A0).Length;
		var sheets = Math.Max( 1, (int)MathF.Round( across / spacing ) );
		var seam = MathF.Min( 0.8f / MathF.Max( across, 1.0f ), 0.2f / sheets );
		for ( var k = 0; k < sheets; k++ )
		{
			var u0 = (float)k / sheets;
			var u1 = (float)(k + 1) / sheets;
			Vector3 P( float f, float u ) => Vector3.Lerp( slope.A( f ), slope.B( f ), u );
			// The sheet, then the seam standing on its right edge.
			var right = k + 1 < sheets ? u1 - seam : u1;
			if ( (P( top, right ) - P( top, u0 )).Length > 0.05f )
				Strip( kit, slope, openings, u0, right, top, thick * 0.5f, thick * 0.5f );
			if ( k + 1 < sheets && (P( top, u1 ) - P( top, right )).Length > 0.01f )
				Strip( kit, slope, openings, right, u1, top, thick * 2.5f, thick * 2.5f );
		}
	}

	/// <summary>
	/// A strip of the slope from p0 p1 (lower edge) to p2 p3 (upper edge), lifted straight up by
	/// <paramref name="low"/> at its lower edge and <paramref name="high"/> at its upper edge.
	/// </summary>
	static void Prism( MeshKit kit, Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float low, float high )
	{
		// The faces must turn outward: the corners go counter clockwise seen from above,
		// whichever way the slope faces.
		var ccw = (p1.x - p0.x) * (p3.y - p0.y) - (p1.y - p0.y) * (p3.x - p0.x) >= 0.0f;
		var corners = ccw ? new[] { p0, p1, p2, p3 } : new[] { p0, p3, p2, p1 };
		var lifts = ccw ? new[] { low, low, high, high } : new[] { low, high, high, low };
		kit.Hex( corners[0], corners[1], corners[2], corners[3],
			corners[0] + Vector3.Up * lifts[0], corners[1] + Vector3.Up * lifts[1], corners[2] + Vector3.Up * lifts[2], corners[3] + Vector3.Up * lifts[3] );
	}

	/// <summary>
	/// Cushions of plants on the substrate of a green roof, one in some cells of a grid, never
	/// touching each other or the parapet.
	/// </summary>
	internal static void Plants( ArchitectureStudioPlan p, RoofStudioSettings s, GenContext context, float x0, float x1, float y0, float y1, float z )
	{
		if ( s.Style != StudioRoofStyle.Green || !s.Plants || s.PlantDensity <= 0.0f ) return;
		var stream = context.Stream( "roof_studio", $"plants{s.PlantSeed}" );
		const float cell = 22.0f;
		var nx = (int)MathF.Floor( (x1 - x0) / cell );
		var ny = (int)MathF.Floor( (y1 - y0) / cell );
		if ( nx < 1 || ny < 1 ) return;
		var ox = x0 + ((x1 - x0) - nx * cell) * 0.5f;
		var oy = y0 + ((y1 - y0) - ny * cell) * 0.5f;
		var density = Math.Clamp( s.PlantDensity, 0, 1 );

		p.Add( "plants", "Plants", p.Finish, kit =>
		{
			for ( var i = 0; i < nx; i++ )
				for ( var j = 0; j < ny; j++ )
				{
					var keep = stream.Next() < density;
					var r = stream.Range( 4.0f, cell * 0.45f - 1.0f );
					var h = stream.Range( 2.0f, 7.0f );
					var jx = stream.Range( -1.0f, 1.0f ) * (cell * 0.5f - r - 0.5f);
					var jy = stream.Range( -1.0f, 1.0f ) * (cell * 0.5f - r - 0.5f);
					if ( !keep ) continue;
					var c = new Vector3( ox + (i + 0.5f) * cell + jx, oy + (j + 0.5f) * cell + jy, 0 );
					kit.Lathe( c, [new( 0, z ), new( r, z ), new( r * 0.9f, z + h * 0.4f ), new( r * 0.6f, z + h * 0.85f ), new( 0, z + h )], 10 );
				}
		} );
	}
}