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()] );
}
}
}