editor/AdvancedMappingTools/Core/DormerKit.cs

Geometry kit that builds a dormer mesh on a pitched roof. DormerSpec holds the shape (gable, shed, flat, hip, barrel, segmental), sizes in inches, window/frame and materials. DormerKit.Fit scales wall and roof rise to fit available roof room, and DormerKit.Build emits the body, window recess, sill, roof solid and flashing into a MeshKit, returning the extent it covers.

namespace AdvancedMappingTools;

/// <summary>
/// The roof of a dormer.
/// </summary>
public enum DormerShape
{
	Gable,
	Shed,
	Flat,
	Hip,
	[Title( "Barrel (Round)" )] Barrel,
	[Title( "Segmental Arch" )] Segmental
}

/// <summary>
/// The pieces of a dormer, to build them into separate parts.
/// </summary>
[Flags]
public enum DormerPieces
{
	Body = 1,
	Window = 2,
	Sill = 4,
	Roof = 8,
	Flashing = 16,
	All = Body | Window | Sill | Roof | Flashing
}

/// <summary>
/// What a dormer looks like. Lengths in inches; the window frame comes from <see cref="WindowSpec"/>.
/// </summary>
public sealed class DormerSpec
{
	public DormerShape Shape { get; set; } = DormerShape.Gable;
	/// <summary>Outer width of the body.</summary>
	public float Width { get; set; } = 48;
	/// <summary>Height of the front wall above the roof, up to the soffit.</summary>
	public float WallHeight { get; set; } = 32;
	/// <summary>Pitch of the main roof in degrees.</summary>
	public float Pitch { get; set; } = 35;
	/// <summary>How far the dormer roof rises above its eaves (gable, hip, arches).</summary>
	public float RoofRise { get; set; } = 16;
	/// <summary>Pitch of a shed dormer's roof in degrees; a flat dormer uses a slight fall.</summary>
	public float ShedPitch { get; set; } = 12;
	public float Overhang { get; set; } = 4;
	/// <summary>Height of the eave (fascia) of the dormer roof.</summary>
	public float RoofThickness { get; set; } = 3;
	public bool Window { get; set; } = true;
	public float WindowWidth { get; set; } = 28;
	public float WindowHeight { get; set; } = 22;
	/// <summary>Height of the window's bottom above the roof at the front wall; raised where the wall is too shallow.</summary>
	public float WindowSill { get; set; } = 6;
	/// <summary>How far the frame sits back from the front wall.</summary>
	public float Reveal { get; set; } = 1.5f;
	public bool Sill { get; set; } = true;
	public bool Flashing { get; set; } = true;
	public float FlashingWidth { get; set; } = 3;
	public WindowSpec Frame { get; set; } = new() { Width = 2.5f, Depth = 3 };
	public Material BodyMaterial { get; set; }
	public Material RoofMaterial { get; set; }
	public Material TrimMaterial { get; set; }
}

/// <summary>
/// Builds a dormer on a pitched roof. Frame of the dormer: X across, the front wall at y = 0
/// facing -Y, Z up; the main roof surface runs through the foot of the front wall and rises
/// toward +Y with the given pitch. Every body ends exactly in the roof surface (a little below it),
/// so the dormer meets the roof cleanly at any pitch.
/// </summary>
public static class DormerKit
{
	/// <summary>
	/// What a built dormer covers on the roof: across from -HalfSpan to HalfSpan, from Front back to Back.
	/// </summary>
	public readonly record struct Result( float HalfSpan, float Front, float Back, float Top );

	const float Embed = 0.0f;

	/// <summary>
	/// The heights a dormer can reach on a slope with <paramref name="room"/> inches of rise left above it.
	/// Wall and roof are scaled down together when they would reach past it.
	/// </summary>
	public static void Fit( DormerSpec s, float room )
	{
		var total = s.WallHeight + s.RoofThickness + (s.Shape is DormerShape.Shed or DormerShape.Flat ? 0 : s.RoofRise);
		if ( total <= room || total <= 0 ) return;
		var k = MathF.Max( room, 4 ) / total;
		s.WallHeight *= k;
		s.RoofRise *= k;
	}

	public static Result Build( MeshKit kit, DormerSpec s, DormerPieces pieces = DormerPieces.All )
	{
		bool Has( DormerPieces piece ) => (pieces & piece) != 0;
		var tan = MathF.Tan( Math.Clamp( s.Pitch, 5, 75 ) * MathF.PI / 180 );
		float Back( float z ) => (z + Embed) / tan;
		var h = MathF.Max( s.Width, 8 ) * 0.5f;
		var t = Math.Clamp( s.RoofThickness, 0.5f, 16 );
		var wall = MathF.Max( s.WallHeight, 4 );
		var o = Math.Clamp( s.Overhang, 0, 48 );
		var a = h + o;
		var rise = MathF.Max( s.RoofRise, 1 );
		var top = wall + t;
		var material = kit.Material;
		var body = s.BodyMaterial ?? material;
		var roof = s.RoofMaterial ?? material;
		var trim = s.TrimMaterial ?? roof;
		var shape = s.Shape;
		var shed = MathF.Tan( Math.Clamp( shape == DormerShape.Flat ? 2 : s.ShedPitch, 0, 60 ) * MathF.PI / 180 );
		if ( shape is DormerShape.Shed or DormerShape.Flat && shed > tan - 0.05f )
			shed = MathF.Max( tan - 0.05f, 0 );

		kit.BeginAssembly();

		// The body: front wall and two cheeks, one wedge standing on the roof.
		var front = new List<Vector3[]>
		{
			new Vector3[] { new( -h, 0, -Embed ), new( h, 0, -Embed ), new( h, 0, wall ), new( -h, 0, wall ) },
			new Vector3[] { new( -h, 0, wall ), new( h, 0, wall ), new( h, Back( wall ), wall ), new( -h, Back( wall ), wall ) },
			new Vector3[] { new( -h, 0, -Embed ), new( h, 0, -Embed ), new( h, Back( wall ), wall ), new( -h, Back( wall ), wall ) },
			new Vector3[] { new( h, 0, -Embed ), new( h, 0, wall ), new( h, Back( wall ), wall ) },
			new Vector3[] { new( -h, 0, -Embed ), new( -h, 0, wall ), new( -h, Back( wall ), wall ) }
		};

		// The window needs room in the wall: its recess must end before the roof below it.
		var frameDepth = MathF.Max( s.Frame.Depth, 0.5f );
		var reveal = Math.Clamp( s.Reveal, 0, 8 );
		var sill = MathF.Max( s.WindowSill, (reveal + frameDepth + 1.5f) * tan - Embed + 0.5f );
		var ww = MathF.Min( s.WindowWidth, h * 2 - 6 );
		var wh = MathF.Min( s.WindowHeight, wall - sill - 2.5f );
		var window = s.Window && ww >= 6 && wh >= 6;

		if ( window && Has( DormerPieces.Body ) )
		{
			var block = new MeshKit { Frame = kit.Frame, Material = body, LiftFlushFaces = false };
			Convex( block, front );
			var cutter = new MeshKit { Frame = kit.Frame, Material = body };
			cutter.Box( new( -ww * 0.5f, -1, sill ), new( ww * 0.5f, reveal + frameDepth, sill + wh ) );
			kit.ImportMesh( MeshCutter.SubtractClean( block.Finish(), cutter.Finish(), Transform.Zero, body ), Transform.Zero );
		}
		else if ( Has( DormerPieces.Body ) )
		{
			kit.Material = body;
			Convex( kit, front );
		}

		if ( window && Has( DormerPieces.Window ) )
		{
			kit.Material = s.Frame.FrameMaterial ?? trim;
			WindowKit.Build( kit, -ww * 0.5f, ww * 0.5f, sill, sill + wh, reveal, s.Frame );
		}

		if ( window && s.Sill && sill > 1.6f && Has( DormerPieces.Sill ) )
		{
			kit.BeginPart();
			kit.Material = trim;
			WindowKit.Sill( kit, -ww * 0.5f, ww * 0.5f, sill, 0, 2, 1.5f, 1 );
		}

		// The roof of the dormer: one solid from the soffit up, ending in the main roof.
		kit.Material = roof;
		var peak = top;
		switch ( shape )
		{
			case DormerShape.Shed:
			case DormerShape.Flat:
			{
				var ridgeY = (top + o * shed + Embed) / (tan - shed);
				var ridgeZ = top + (ridgeY + o) * shed;
				peak = ridgeZ;
				Vector3[] Side( float x ) => [new( x, -o, wall ), new( x, -o, top ), new( x, ridgeY, ridgeZ ), new( x, Back( wall ), wall )];
				if ( Has( DormerPieces.Roof ) )
					Convex( kit, Extrude( Side( -a ), Side( a ) ) );
				break;
			}
			case DormerShape.Hip when Back( top + rise ) - (-o + a) > 0.5f:
			{
				peak = top + rise;
				var r0 = new Vector3( 0, -o + a, top + rise );
				var r1 = new Vector3( 0, Back( top + rise ), top + rise );
				Vector3 P( float x, float y, float z ) => new( x, y, z );
				if ( Has( DormerPieces.Roof ) )
					Convex( kit,
				[
					[P( -a, -o, wall ), P( a, -o, wall ), P( a, Back( wall ), wall ), P( -a, Back( wall ), wall )],
					[P( -a, -o, wall ), P( a, -o, wall ), P( a, -o, top ), P( -a, -o, top )],
					[P( a, -o, wall ), P( a, Back( wall ), wall ), P( a, Back( top ), top ), P( a, -o, top )],
					[P( -a, -o, wall ), P( -a, Back( wall ), wall ), P( -a, Back( top ), top ), P( -a, -o, top )],
					[P( -a, -o, top ), P( a, -o, top ), r0],
					[P( a, -o, top ), P( a, Back( top ), top ), r1, r0],
					[P( -a, -o, top ), P( -a, Back( top ), top ), r1, r0],
					[P( -a, Back( wall ), wall ), P( a, Back( wall ), wall ), P( a, Back( top ), top ), r1, P( -a, Back( top ), top )]
				] );
				break;
			}
			default:
			{
				// Gable, and hip roofs too short for a ridge; barrel and segmental arches.
				var profile = new List<Vector2> { new( -a, wall ), new( a, wall ), new( a, top ) };
				if ( shape is DormerShape.Barrel or DormerShape.Segmental )
				{
					var sag = shape == DormerShape.Barrel ? a : MathF.Min( rise, a );
					var radius = (a * a + sag * sag) / (2 * sag);
					var centre = top + sag - radius;
					var half = MathF.Asin( Math.Clamp( a / radius, -1, 1 ) );
					var steps = Math.Clamp( (int)MathF.Ceiling( half * 2 / (MathF.PI / 12) ), 4, 16 );
					for ( var i = 1; i < steps; i++ )
					{
						var angle = -half + half * 2 * i / steps;
						profile.Add( new( radius * MathF.Sin( -angle ), centre + radius * MathF.Cos( angle ) ) );
					}

					peak = top + sag;
				}
				else
				{
					profile.Add( new( 0, top + rise ) );
					peak = top + rise;
				}

				profile.Add( new( -a, top ) );
				var near = profile.Select( p => new Vector3( p.x, -o, p.y ) ).ToArray();
				var far = profile.Select( p => new Vector3( p.x, Back( p.y ), p.y ) ).ToArray();
				if ( Has( DormerPieces.Roof ) )
					Convex( kit, Extrude( near, far ) );
				break;
			}
		}

		// Flashing where the cheeks and the front wall meet the roof.
		var sheet = s.Flashing ? Math.Clamp( s.FlashingWidth, 0.5f, 12 ) : 0;
		if ( sheet > 0 && Has( DormerPieces.Flashing ) )
		{
			kit.Material = trim;
			var normal = new Vector3( 0, -MathF.Sin( MathF.Atan( tan ) ), MathF.Cos( MathF.Atan( tan ) ) ) * 0.25f;
			Vector3 On( float x, float y ) => new( x, y, y * tan );
			void Plate( float x0, float x1, float y0, float y1 )
			{
				kit.BeginPart();
				kit.Hex( On( x0, y0 ), On( x1, y0 ), On( x1, y1 ), On( x0, y1 ), On( x0, y0 ) + normal, On( x1, y0 ) + normal, On( x1, y1 ) + normal, On( x0, y1 ) + normal );
			}

			var end = (wall - 0.6f) / tan;
			Plate( -h - sheet, h + sheet, -sheet, 0 );
			Plate( h, h + sheet, 0, end );
			Plate( -h - sheet, -h, 0, end );
		}

		kit.EndAssembly();
		kit.Material = material;
		return new Result( MathF.Max( a, h + sheet ), -MathF.Max( o, sheet ), Back( peak ), peak );
	}

	/// <summary>The faces of a body between two matching outlines.</summary>
	static List<Vector3[]> Extrude( Vector3[] near, Vector3[] far )
	{
		var faces = new List<Vector3[]> { near, far };
		for ( var i = 0; i < near.Length; i++ )
		{
			var j = (i + 1) % near.Length;
			faces.Add( [near[i], near[j], far[j], far[i]] );
		}

		return faces;
	}

	/// <summary>
	/// A convex body from its faces in any winding: each is turned to face away from the middle.
	/// </summary>
	static void Convex( MeshKit kit, List<Vector3[]> faces )
	{
		kit.BeginPart();
		var all = faces.SelectMany( f => f ).ToList();
		var middle = all.Aggregate( Vector3.Zero, ( sum, p ) => sum + p ) / all.Count;
		foreach ( var face in faces )
		{
			var centre = face.Aggregate( Vector3.Zero, ( sum, p ) => sum + p ) / face.Length;
			var normal = Vector3.Zero;
			for ( var i = 0; i < face.Length; i++ )
				normal += Vector3.Cross( face[i], face[(i + 1) % face.Length] );
			kit.Face( Vector3.Dot( normal, centre - middle ) >= 0 ? face : [.. face.Reverse()] );
		}
	}
}