editor/AdvancedMappingTools/Geometry/RoofHouseDetails.cs

Partial class for the RoofEditor editor tool. Builds the roof body mesh, cuts real openings for roof windows and dormers, places window frames, glass and flashing, and adds gutters and downpipes. Also drives which detail properties are shown in the inspector.

namespace AdvancedMappingTools;
public sealed partial class RoofEditor
{
	public string LastRoofDetailDiagnostics { get; private set; }

	string _roofDetailsSummary = "Enable roof windows or cut dormers to preview openings.";
	[Property, ReadOnly, Group( "Roof Windows" ), Title( "Opening Status" ), WideMode]
	public string RoofDetailsSummary => _roofDetailsSummary;

	[Property, Group( "Drainage" ), WideMode]
	public bool Downpipes { get; set; }

	[Property, Group( "Drainage" ), Title( "Evenly Spaced Downpipes" ), WideMode]
	public bool DistributeDownpipes { get; set; }

	[Property, Group( "Drainage" ), Range( 32, 2048, slider: false ), WideMode]
	public float DownpipeSpacing { get; set; } = 256;

	[Property, Group( "Drainage" ), Range( 16, 4096, slider: false ), WideMode]
	public float DownpipeDrop { get; set; } = 128;

	[Property, Group( "Drainage" ), Range( .5f, 12, slider: false ), WideMode]
	public float DownpipeRadius { get; set; } = 2;

	[Property, Group( "Drainage" ), WideMode]
	public bool PipeBrackets { get; set; } = true;

	[Property, Group( "Drainage" ), WideMode]
	public bool GutterEndReturns { get; set; }

	[Property, Group( "Materials" ), Title( "Roof Interior" ), WideMode]
	public Material RoofInteriorSurface { get; set; }

	[Property, Group( "Materials" ), Title( "Gutters / Pipes" ), WideMode]
	public Material GutterSurface { get; set; }

	[Property, Group( "Materials" ), Title( "Roof Window Frame" ), WideMode]
	public Material RoofWindowFrameSurface { get; set; }

	[Property, Group( "Materials" ), Title( "Roof Window Glass" ), WideMode]
	public Material RoofWindowGlassSurface { get; set; }

	[Property, Group( "Roof Windows" ), Title( "Roof Windows (Real Cut)" ), WideMode]
	public bool RoofWindows { get; set; }

	[Property, Group( "Roof Windows" ), Range( 1, 8, slider: false ), WideMode]
	public int RoofWindowCount { get; set; } = 2;

	[Property, Group( "Roof Windows" ), Range( 8, 256, slider: false ), WideMode]
	public float RoofWindowWidth { get; set; } = 40;

	[Property, Group( "Roof Windows" ), Range( 8, 256, slider: false ), WideMode]
	public float RoofWindowDepth { get; set; } = 40;

	[Property, Group( "Roof Windows" ), Title( "Across Roof (%)" ), Range( 10, 90, slider: false ), WideMode]
	public float RoofWindowPosition { get; set; } = 25;

	[Property, Group( "Roof Windows" ), WideMode]
	public bool RoofWindowFrames { get; set; } = true;

	[Property, Group( "Roof Windows" ), WideMode]
	public bool RoofWindowGlass { get; set; } = true;

	[Property, Group( "Roof Windows" ), Title( "Frame Corners" ), WideMode, Description( "How the four frame bars meet: mitred, butt, welded, with corner blocks, plates or pegs." )]
	public FrameCorner RoofWindowCorner { get; set; } = FrameCorner.Mitred;

	[Property, Group( "Roof Windows" ), Title( "Frame Profile" ), WideMode]
	public FrameProfile RoofWindowProfile { get; set; } = FrameProfile.Chamfered;

	[Property, Group( "Roof Windows" ), Title( "Frame Detail" ), WideMode, Description( "Basic: frame and pane. Standard: glass sits in a groove. Detailed and High: glazing bead, gasket, spacer between panes." )]
	public WindowDetail RoofWindowDetail { get; set; } = WindowDetail.Standard;

	[Property, Group( "Roof Windows" ), Title( "Frame Width" ), Range( 1, 16, slider: false ), WideMode]
	public float RoofWindowFrameWidth { get; set; } = 3;

	[Property, Group( "Roof Windows" ), Title( "Height Above Roof" ), Range( 1, 16, slider: false ), WideMode]
	public float RoofWindowCurb { get; set; } = 3;

	[Property, Group( "Roof Windows" ), Title( "Glazing" ), WideMode]
	public Glazing RoofWindowGlazing { get; set; } = Glazing.Double;

	[Property, Group( "Roof Windows" ), Title( "Panes Across" ), Range( 1, 6, slider: false ), WideMode]
	public int RoofWindowPanesAcross { get; set; } = 1;

	[Property, Group( "Roof Windows" ), Title( "Panes Up The Slope" ), Range( 1, 6, slider: false ), WideMode]
	public int RoofWindowPanesUp { get; set; } = 1;

	[Property, Group( "Roof Windows" ), Title( "Flashing" ), WideMode, Description( "Sheet metal on the roof around the frame, with an apron below." )]
	public bool RoofWindowFlashing { get; set; } = true;

	[Property, Group( "Roof Windows" ), Title( "Flashing Width" ), Range( 1, 16, slider: false ), WideMode]
	public float RoofWindowFlashingWidth { get; set; } = 3;

	/// <summary>
	/// Holes in the roof with what lies around them, in the roof's own length and depth. Tile rows stop there.
	/// </summary>
	readonly List<(float X0, float X1, float Y0, float Y1)> _roofOpenings = [];

	[Property, Group( "Dormers" ), Title( "Enable Dormers" ), WideMode]
	public bool EnableDormers { get; set; } = true;

	[Property, Group( "Dormers" ), Title( "Cut Dormer Openings" ), WideMode]
	public bool CutDormerOpenings { get; set; }

	[Property, Group( "Dormers" ), Title( "Dormer Glass" ), WideMode]
	public bool DormerGlass { get; set; } = true;

	static readonly string[] PlainDormerOptions =
	[
		nameof( DormerRoof ), nameof( DormerRoofRise ), nameof( DormerOverhang ), nameof( DormersBothSides ), nameof( DormerWindow ), nameof( DormerWindowWidth ),
		nameof( DormerWindowHeight ), nameof( DormerWindowCorner ), nameof( DormerWindowDetail ), nameof( DormerFlashing )
	];

	void HideHouseDetailOptions( HashSet<string> h )
	{
		if ( !Gutters )
			h.UnionWith( [nameof( Downpipes ), nameof( GutterEndReturns ), nameof( GutterSurface )] );
		if ( !Gutters || !Downpipes )
			h.UnionWith( [nameof( DownpipeSpacing ), nameof( DownpipeDrop ), nameof( DownpipeRadius ), nameof( PipeBrackets ), nameof( DistributeDownpipes )] );
		if ( Shape != RoofShape.Gable )
			h.UnionWith( [nameof( EnableDormers ), nameof( Dormers ), nameof( DormerWidth ), nameof( DormerHeight ), nameof( RidgeCap ), nameof( TileRows ), .. PlainDormerOptions] );
		if ( !EnableDormers )
			h.UnionWith( [nameof( Dormers ), nameof( DormerWidth ), nameof( DormerHeight ), nameof( CutDormerOpenings ), nameof( DormerGlass ), .. PlainDormerOptions] );
		else if ( Hollow && CutDormerOpenings )
			h.UnionWith( PlainDormerOptions );
		else if ( !DormerWindow )
			h.UnionWith( [nameof( DormerWindowWidth ), nameof( DormerWindowHeight ), nameof( DormerWindowCorner ), nameof( DormerWindowDetail )] );
		if ( DormerRoof is DormerShape.Shed or DormerShape.Flat or DormerShape.Barrel )
			h.Add( nameof( DormerRoofRise ) );
		if ( !DistributeDownpipes )
			h.Add( nameof( DownpipeSpacing ) );
		if ( !Hollow )
			h.UnionWith( [nameof( RoofInteriorSurface ), nameof( CutDormerOpenings )] );
		if ( !RoofWindows )
			h.UnionWith( [nameof( RoofWindowCount ), nameof( RoofWindowWidth ), nameof( RoofWindowDepth ), nameof( RoofWindowPosition ), nameof( RoofWindowFrames ), nameof( RoofWindowGlass ), nameof( RoofWindowCorner ), nameof( RoofWindowProfile ), nameof( RoofWindowDetail ), nameof( RoofWindowFrameWidth ), nameof( RoofWindowCurb ), nameof( RoofWindowGlazing ), nameof( RoofWindowPanesAcross ), nameof( RoofWindowPanesUp ), nameof( RoofWindowFlashing ), nameof( RoofWindowFlashingWidth )] );
		else
		{
			if ( !RoofWindowFrames )
				h.UnionWith( [nameof( RoofWindowCorner ), nameof( RoofWindowProfile ), nameof( RoofWindowDetail ), nameof( RoofWindowFrameWidth ), nameof( RoofWindowCurb ), nameof( RoofWindowGlazing ), nameof( RoofWindowPanesAcross ), nameof( RoofWindowPanesUp ), nameof( RoofWindowFlashing ), nameof( RoofWindowFlashingWidth )] );
			else if ( !RoofWindowGlass )
				h.Add( nameof( RoofWindowGlazing ) );
			if ( RoofWindowFrames && !RoofWindowFlashing )
				h.Add( nameof( RoofWindowFlashingWidth ) );
		}
		if ( !RoofWindows && (!Hollow || !CutDormerOpenings) )
			h.UnionWith( [nameof( RoofWindowFrameSurface ), nameof( RoofWindowGlassSurface )] );
		if ( !Hollow || Shape != RoofShape.Gable || Dormers == 0 )
			h.Add( nameof( CutDormerOpenings ) );
		if ( !Hollow || !CutDormerOpenings || Shape != RoofShape.Gable || Dormers == 0 )
			h.Add( nameof( DormerGlass ) );
	}

	void AddRoofDrainage( MeshKit kit, Transform frame, float x0, float x1, float front, float back, float z, float width, float height )
	{
		var destination = kit;
		kit = new MeshKit
		{
			Frame = frame,
			LiftFlushFaces = false
		};
		kit.Material = GutterSurface ?? TrimMaterial;
		var wallSkin = MathF.Min( Math.Clamp( GutterWallThickness, .25f, 4 ), MathF.Min( width, height ) * .4f );
		if ( Downpipes )
			wallSkin = MathF.Min( wallSkin, width * .25f );
		var floors = new List<(Vector3 Min, Vector3 Max)>();
		var outlets = new List<PolygonMesh>();
		void Floor( Vector3 min, Vector3 max ) => floors.Add( (min, max) );
		void AppendFloors()
		{
			foreach ( var floor in floors )
			{
				var meshKit = new MeshKit
				{
					Frame = frame,
					LiftFlushFaces = false,
					Material = GutterSurface ?? TrimMaterial
				};
				meshKit.Box( floor.Min, floor.Max );
				var mesh = meshKit.Finish();
				foreach ( var outlet in outlets )
				{
					if ( !mesh.CalculateBounds().Overlaps( outlet.CalculateBounds() ) )
						continue;
					var result = MeshCutter.SubtractClean( mesh, outlet, Transform.Zero, GutterSurface ?? TrimMaterial );
					if ( result != null )
						mesh = result;
				}

				destination.ImportMesh( mesh, Transform.Zero );
			}
		}

		if ( !GutterEndReturns )
			foreach ( var side in new[]
			{
				-1,
				1
			}

 )
			{
				if ( side < 0 ? !FrontGutter : !BackGutter )
					continue;
				var y = side < 0 ? front : back;
				var low = y - width * .5f;
				var high = y + width * .5f;
				var bottom = z - height * .5f;
				Floor( new( x0, low, bottom ), new( x1, high, bottom + wallSkin ) );
				kit.Box( new( x0, low, bottom + wallSkin ), new( x1, low + wallSkin, bottom + height ) );
				kit.Box( new( x0, high - wallSkin, bottom + wallSkin ), new( x1, high, bottom + height ) );
			}

		if ( GutterEndReturns && FrontGutter && BackGutter )
		{
			width = MathF.Min( width, MathF.Min( x1 - x0, back - front ) * .4f );
			var skin = wallSkin;
			var loX = x0 - width * .5f;
			var hiX = x1 + width * .5f;
			var loY = front - width * .5f;
			var hiY = back + width * .5f;
			var inX = x0 + width * .5f;
			var endX = x1 - width * .5f;
			var inY = front + width * .5f;
			var endY = back - width * .5f;
			var bottom = z - height * .5f;
			void Ring( float a, float b, float c, float d, float bar, float low, float high )
			{
				kit.Box( new( a, b, low ), new( c, b + bar, high ) );
				kit.Box( new( a, d - bar, low ), new( c, d, high ) );
				kit.Box( new( a, b + bar, low ), new( a + bar, d - bar, high ) );
				kit.Box( new( c - bar, b + bar, low ), new( c, d - bar, high ) );
			}

			Floor( new( loX, loY, bottom ), new( hiX, inY, bottom + skin ) );
			Floor( new( loX, endY, bottom ), new( hiX, hiY, bottom + skin ) );
			Floor( new( loX, inY, bottom ), new( inX, endY, bottom + skin ) );
			Floor( new( endX, inY, bottom ), new( hiX, endY, bottom + skin ) );
			Ring( loX, loY, hiX, hiY, skin, bottom + skin, z + height * .5f );
			Ring( inX - skin, inY - skin, endX + skin, endY + skin, skin, bottom + skin, z + height * .5f );
		}

		if ( GutterEndReturns && FrontGutter != BackGutter )
		{
			var w = MathF.Min( width, MathF.Min( x1 - x0, back - front ) * .4f );
			var skin = MathF.Min( wallSkin, w * .25f );
			var loX = x0 - w * .5f;
			var hiX = x1 + w * .5f;
			var loY = front - w * .5f;
			var hiY = back + w * .5f;
			var inX = x0 + w * .5f;
			var endX = x1 - w * .5f;
			var inY = front + w * .5f;
			var endY = back - w * .5f;
			var bottom = z - height * .5f;
			var top = z + height * .5f;
			if ( FrontGutter )
				Floor( new( loX, loY, bottom ), new( hiX, inY, bottom + skin ) );
			if ( BackGutter )
				Floor( new( loX, endY, bottom ), new( hiX, hiY, bottom + skin ) );
			var first = FrontGutter ? inY : loY;
			var last = BackGutter ? endY : hiY;
			Floor( new( loX, first, bottom ), new( inX, last, bottom + skin ) );
			Floor( new( endX, first, bottom ), new( hiX, last, bottom + skin ) );
			if ( FrontGutter )
			{
				kit.Box( new( loX, loY, bottom + skin ), new( hiX, loY + skin, top ) );
				kit.Box( new( inX - skin, inY - skin, bottom + skin ), new( endX + skin, inY, top ) );
			}
			else
			{
				kit.Box( new( loX, loY, bottom + skin ), new( inX, loY + skin, top ) );
				kit.Box( new( endX, loY, bottom + skin ), new( hiX, loY + skin, top ) );
			}

			if ( BackGutter )
			{
				kit.Box( new( loX, hiY - skin, bottom + skin ), new( hiX, hiY, top ) );
				kit.Box( new( inX - skin, endY, bottom + skin ), new( endX + skin, endY + skin, top ) );
			}
			else
			{
				kit.Box( new( loX, hiY - skin, bottom + skin ), new( inX, hiY, top ) );
				kit.Box( new( endX, hiY - skin, bottom + skin ), new( hiX, hiY, top ) );
			}

			kit.Box( new( loX, loY + skin, bottom + skin ), new( loX + skin, hiY - skin, top ) );
			kit.Box( new( hiX - skin, loY + skin, bottom + skin ), new( hiX, hiY - skin, top ) );
			kit.Box( new( inX - skin, FrontGutter ? inY : loY + skin, bottom + skin ), new( inX, BackGutter ? endY : hiY - skin, top ) );
			kit.Box( new( endX, FrontGutter ? inY : loY + skin, bottom + skin ), new( endX + skin, BackGutter ? endY : hiY - skin, top ) );
		}

		var channel = kit.Finish();
		if ( !Downpipes )
		{
			destination.ImportMesh( channel, Transform.Zero );
			AppendFloors();
			return;
		}

		kit = new MeshKit
		{
			Frame = frame,
			LiftFlushFaces = false,
			Material = GutterSurface ?? TrimMaterial
		};
		var count = DistributeDownpipes ? Math.Clamp( (int)MathF.Ceiling( (x1 - x0) / Math.Clamp( DownpipeSpacing, 32, 2048 ) ), 1, 16 ) : 2;
		var drop = Math.Clamp( DownpipeDrop, 16, 4096 );
		var radius = MathF.Min( MathF.Min( Math.Clamp( DownpipeRadius, .5f, 12 ), drop * .125f ), MathF.Max( .5f, (width - wallSkin * 2) * .45f ) );
		foreach ( var side in new[]
		{
			-1,
			1
		}

 )
			for ( var i = 0; i < (ManualDownpipePosition ? 1 : count); i++ )
			{
				if ( side < 0 ? (!FrontGutter || !FrontDownpipes) : (!BackGutter || !BackDownpipes) )
					continue;
				var x = DistributeDownpipes ? Math.Clamp( (x1 + x0) * .5f + (i - (count - 1) * .5f) * (x1 - x0) / count, x0 + radius * 2, x1 - radius * 2 ) : (i == 0 ? MathF.Max( WallMargin, 2 ) + radius * 2 : RectLength - MathF.Max( WallMargin, 2 ) - radius * 2);
				if ( ManualDownpipePosition )
					x = x0 + (x1 - x0) * Math.Clamp( DownpipeAlong, 0, 100 ) / 100;
				x = Math.Clamp( x, x0 + radius + 1, x1 - radius - 1 );
				var y = side < 0 ? front : back;
				{
					var cutter = new MeshKit
					{
						Frame = frame,
						Material = GutterSurface ?? TrimMaterial,
						LiftFlushFaces = false
					};
					cutter.Cylinder( new Vector3( x, y, 0 ), radius, radius, z - height - 1, z + height + 1, 12 );
					outlets.Add( cutter.Finish() );
				}

				var wallY = side < 0 ? -MathF.Max( WallMargin, 2 ) - radius * 2 : RectDepth + MathF.Max( WallMargin, 2 ) + radius * 2;
				var knee = MathF.Min( 16, drop * .25f );
				HouseDetailGeometry.Tube( kit, [new( x, y, z ), new( x, y, z - knee ), new( x, wallY, z - knee * 2 ), new( x, wallY, z - drop + radius * 2 ), new( x, wallY + side * radius * 4, z - drop )], radius, .5f );
				if ( PipeBrackets )
					for ( var at = z - knee * 2; at > z - drop + 8; at -= 64 )
						kit.Beam( new( x, wallY, at ), new( x, side < 0 ? 0 : RectDepth, at ), 1, 2 );
			}

		destination.ImportMesh( channel, Transform.Zero );
		AppendFloors();
		destination.ImportMesh( kit.Finish(), Transform.Zero );
	}

	protected override void BuildMesh( MeshKit kit )
	{
		_roofOpenings.Clear();
		if ( !RoofWindows && !(Hollow && EnableDormers && CutDormerOpenings && Dormers > 0 && Shape == RoofShape.Gable) )
		{
			_roofDetailsSummary = "Roof windows and cut dormers are off.";
			BuildRoofBodyMesh( kit );
			return;
		}

		var body = new MeshKit
		{
			Material = SurfaceMaterial,
			LiftFlushFaces = false
		};
		BuildRoofBodyMesh( body, 1 );
		var mesh = body.Finish();
		var frame = RectFrame;
		frame.Position += Vector3.Up * Elevation;
		RoofBounds( out var roofX0, out var roofX1, out var roofY0, out var roofY1 );
		var skin = new MeshKit
		{
			LiftFlushFaces = false
		};
		BuildRoof( skin, Transform.Zero, roofX0, roofX1, roofY0, roofY1, Shape, RidgeHeight, EdgeThickness, SurfaceMaterial, TrimMaterial, CustomProfile );
		var sample = skin.Finish();
		var placedWindows = 0;
		var skippedWindows = 0;
		var placedDormers = 0;
		var extras = new MeshKit
		{
			Frame = frame,
			Material = RoofWindowFrameSurface ?? TrimMaterial,
			LiftFlushFaces = false
		};
		void Cut( float cx, float cy, float w, float d )
		{
			var cutter = new MeshKit
			{
				Frame = frame,
				Material = TrimMaterial
			};
			cutter.Box( new( cx - w * .5f, cy - d * .5f, -4096 ), new( cx + w * .5f, cy + d * .5f, RidgeHeight + EdgeThickness + 4096 ) );
			mesh = MeshCutter.SubtractClean( mesh, cutter.Finish(), Transform.Zero, RoofInteriorSurface ?? TrimMaterial );
		}

		if ( RoofWindows )
			for ( var i = 0; i < Math.Clamp( RoofWindowCount, 1, 8 ); i++ )
			{
				var slot = RectLength / (Math.Clamp( RoofWindowCount, 1, 8 ) + 1);
				var cx = slot * (i + 1);
				var cy = RectDepth * Math.Clamp( RoofWindowPosition, 10, 90 ) / 100;
				var w = MathF.Min( Math.Clamp( RoofWindowWidth, 8, 256 ), slot * .7f );
				var d = MathF.Min( Math.Clamp( RoofWindowDepth, 8, 256 ), MathF.Min( cy, RectDepth - cy ) * 1.5f );
				var overlap = false;
				if ( Hollow && EnableDormers && CutDormerOpenings && Dormers > 0 && Shape == RoofShape.Gable )
					for ( var j = 0; j < Math.Clamp( Dormers, 1, 16 ); j++ )
					{
						var ds = RectLength / Math.Clamp( Dormers, 1, 16 );
						var dc = ds * (j + .5f);
						var dw = MathF.Min( Math.Clamp( DormerWidth, 8, 256 ), ds * .7f );
						if ( MathF.Abs( cx - dc ) < (w + dw) * .5f + 4 && cy + d * .5f > RectDepth * .2f - 4 && cy - d * .5f < RectDepth * .5f + 4 )
							overlap = true;
					}

				if ( w < 8 || d < 8 || overlap )
				{
					skippedWindows++;
					continue;
				}

				var points = new[]
				{
					new Vector2( cx - w * .5f, cy - d * .5f ),
					new Vector2( cx + w * .5f, cy - d * .5f ),
					new Vector2( cx + w * .5f, cy + d * .5f ),
					new Vector2( cx - w * .5f, cy + d * .5f )
				};
				var heights = points.Select( p => HouseDetailGeometry.Height( sample, p ) ).ToArray();
				if ( heights.Any( h => !float.IsFinite( h ) ) )
				{
					skippedWindows++;
					continue;
				}

				placedWindows++;
				Cut( cx, cy, w, d );
				var glassMaterial = RoofWindowGlassSurface ?? Material.Load( "materials/glass/glass_a.vmat" );
				var on = points.Select( ( p, j ) => new Vector3( p.x, p.y, heights[j] ) ).ToArray();
				var start = 0;
				for ( var j = 1; j < 4; j++ )
					if ( (on[(j + 3) % 4] - on[j]).Normal.z > (on[(start + 3) % 4] - on[start]).Normal.z + .001f )
						start = j;
				var across = on[(start + 1) % 4] - on[start];
				var up = on[(start + 3) % 4] - on[start];
				var middleHeight = HouseDetailGeometry.Height( sample, new Vector2( cx, cy ) );
				var level = float.IsFinite( middleHeight )
					&& MathF.Abs( middleHeight - (heights[0] + heights[2]) * .5f ) < .25f
					&& MathF.Abs( heights[0] + heights[2] - heights[1] - heights[3] ) < .25f
					&& MathF.Abs( Vector3.Dot( across.Normal, up.Normal ) ) < .01f;
				if ( !level )
				{
					// Across a ridge or a hip there is no one plane to lay a window in: bars on the roof, as before.
					var top = on.Select( p => p + Vector3.Up * 3 ).ToArray();
					extras.Frame = frame;
					extras.Material = RoofWindowFrameSurface ?? TrimMaterial;
					if ( RoofWindowFrames )
						for ( var j = 0; j < 4; j++ )
							extras.Beam( top[j], top[(j + 1) % 4], 3, 6 );
					if ( RoofWindowGlass )
					{
						extras.Material = glassMaterial;
						extras.Hex( top[0], top[1], top[2], top[3], top[0] + Vector3.Up * .5f, top[1] + Vector3.Up * .5f, top[2] + Vector3.Up * .5f, top[3] + Vector3.Up * .5f );
					}

					_roofOpenings.Add( (cx - w * .5f - 4, cx + w * .5f + 4, cy - d * .5f - 4, cy + d * .5f + 4) );
					continue;
				}

				// The window lies in the plane of the roof: along the eave and up the slope, its face square to the roof.
				extras.Frame = frame.ToWorld( new Transform( on[start], Rotation.LookAt( across.Normal, up.Normal ) ) );
				var reach = 1f;
				if ( RoofWindowFrames )
				{
					var bar = MathF.Min( Math.Clamp( RoofWindowFrameWidth, 1, 16 ), MathF.Min( across.Length, up.Length ) * .2f );
					var lap = bar * .5f;
					var curb = Math.Clamp( RoofWindowCurb, 1, 16 );
					var spec = new WindowSpec
					{
						Width = bar,
						Depth = curb,
						Edge = MathF.Min( .75f, bar * .3f ),
						Corner = RoofWindowCorner,
						Profile = RoofWindowProfile,
						Detail = RoofWindowDetail,
						Glass = RoofWindowGlass,
						Glazing = RoofWindowGlazing,
						Columns = Math.Clamp( RoofWindowPanesAcross, 1, 6 ),
						Rows = Math.Clamp( RoofWindowPanesUp, 1, 6 ),
						FrameMaterial = RoofWindowFrameSurface ?? TrimMaterial,
						GlassMaterial = glassMaterial,
						DetailMaterial = GutterSurface ?? TrimMaterial
					};
					WindowKit.Build( extras, -lap, across.Length + lap, -lap, up.Length + lap, -curb, spec );
					reach = lap + 1;
					if ( RoofWindowFlashing )
					{
						var sheet = Math.Clamp( RoofWindowFlashingWidth, 1, 16 );
						extras.Material = GutterSurface ?? TrimMaterial;
						WindowKit.Flashing( extras, -lap, across.Length + lap, -lap, up.Length + lap, 0, sheet, sheet, MathF.Min( curb * .6f, 2 ) );
						reach = lap + sheet * 2 + 1;
					}
				}
				else if ( RoofWindowGlass )
				{
					extras.Material = glassMaterial;
					extras.Box( new( -1, -.5f, -1 ), new( across.Length + 1, 0, up.Length + 1 ) );
					reach = 2;
				}

				extras.Frame = frame;
				_roofOpenings.Add( (cx - w * .5f - reach, cx + w * .5f + reach, cy - d * .5f - reach, cy + d * .5f + reach) );
			}

		if ( Hollow && EnableDormers && CutDormerOpenings && Dormers > 0 && Shape == RoofShape.Gable )
			for ( var i = 0; i < Math.Clamp( Dormers, 1, 16 ); i++ )
			{
				var slot = RectLength / Math.Clamp( Dormers, 1, 16 );
				var cx = slot * (i + .5f);
				var w = MathF.Min( Math.Clamp( DormerWidth, 8, 256 ), slot * .7f );
				var front = RectDepth * .2f;
				var back = RectDepth * .5f;
				if ( w < 8 || back - front < 8 )
					continue;
				placedDormers++;
				_roofOpenings.Add( (cx - w * .5f - 3, cx + w * .5f + 3, front - 3, back + 3) );
				var low = HouseDetailGeometry.Height( sample, new( cx, front ) ) - MathF.Max( ShellThickness, 2 );
				var top = HouseDetailGeometry.Height( sample, new( cx, back ) ) + Math.Clamp( DormerHeight, 8, 256 );
				var bar = MathF.Min( 3, w * .1f );
				Cut( cx, (front + back) * .5f, w - bar * 2, back - front - bar * 2 );
				extras.Frame = frame;
				extras.Material = RoofWindowFrameSurface ?? TrimMaterial;
				extras.Box( new( cx - w * .5f, front, low ), new( cx - w * .5f + bar, back, top ) );
				extras.Box( new( cx + w * .5f - bar, front, low ), new( cx + w * .5f, back, top ) );
				extras.Box( new( cx - w * .5f + bar, front, low ), new( cx + w * .5f - bar, front + bar, low + bar ) );
				extras.Box( new( cx - w * .5f + bar, front, top - bar ), new( cx + w * .5f - bar, front + bar, top ) );
				var dormerFrame = frame.ToWorld( new Transform( new Vector3( cx + w * .5f + 2, front - 2, top ), Rotation.FromYaw( 90 ) ) );
				extras.Frame = dormerFrame;
				BuildRoof( extras, dormerFrame, 0, back - front + 4, 0, w + 4, RoofShape.Gable, w * .3f, 2, SurfaceMaterial, TrimMaterial );
				extras.Frame = frame;
				if ( DormerGlass )
				{
					extras.Material = RoofWindowGlassSurface ?? Material.Load( "materials/glass/glass_a.vmat" );
					extras.Box( new( cx - w * .5f + bar, front + 1, low + bar ), new( cx + w * .5f - bar, front + 1.5f, top - bar ) );
				}
			}

		_roofDetailsSummary = $"Roof windows: {placedWindows}; dormers: {placedDormers}; windows skipped (space / overlap): {skippedWindows}.";
		var additions = extras.Finish();
		LastRoofDetailDiagnostics = $"Cut faces={mesh?.FaceHandles.Count()}, cut open={mesh?.FaceHandles.Count( f => mesh.GetFaceEdges( f ).Any( mesh.IsEdgeOpen ) )}, detail faces={additions?.FaceHandles.Count()}, detail open={additions?.FaceHandles.Count( f => additions.GetFaceEdges( f ).Any( additions.IsEdgeOpen ) )}";
		HouseDetailGeometry.Append( kit, mesh );
		HouseDetailGeometry.Append( kit, additions );
		BuildRoofBodyMesh( kit, 2 );
	}
}