Editor-side settings and geometry helpers for roof windows in the Advanced Mapping Tools roof generator. Defines the RoofWindow properties on RoofStudioSettings, decides which options to hide, and computes where window openings sit on each roof slope plus the covering strips that leave those openings free.
namespace AdvancedMappingTools;
public sealed partial class RoofStudioSettings
{
[Property, Group( "Roof Windows" ), Title( "Roof Windows" ), WideMode, Description( "Windows lying in the slope: a real opening in the roof, a profiled frame, glass and flashing. The covering stops around them." )]
public bool RoofWindows { get; set; }
[Property, Group( "Roof Windows" ), Title( "Windows Per Slope" ), Range( 1, 8, slider: false ), WideMode]
public int RoofWindowCount { get; set; } = 2;
[Property, Group( "Roof Windows" ), Title( "Width" ), Range( 8, 256, slider: false ), WideMode]
public float RoofWindowWidth { get; set; } = 32;
[Property, Group( "Roof Windows" ), Title( "Length Up The Slope" ), Range( 8, 256, slider: false ), WideMode]
public float RoofWindowLength { get; set; } = 44;
[Property, Group( "Roof Windows" ), Title( "Height On The Slope (%)" ), Range( 10, 90, slider: false ), WideMode]
public float RoofWindowPosition { get; set; } = 40;
[Property, Group( "Roof Windows" ), Title( "On Both Sides" ), WideMode, Description( "Windows on the back slope too." )]
public bool RoofWindowsBothSides { get; set; }
[Property, Group( "Roof Windows" ), Title( "Frame Corners" ), WideMode]
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]
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, Description( "At least as high as the covering, so the frame always stands out of it." )]
public float RoofWindowCurb { get; set; } = 4;
[Property, Group( "Roof Windows" ), Title( "Glass" ), WideMode]
public bool RoofWindowGlass { get; set; } = true;
[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;
[Property, Group( "Materials" ), Title( "Roof Window Glass" ), ResourceType( "vmat" ), WideMode]
public string RoofWindowGlassMaterial { get; set; } = "materials/glass/glass_a.vmat";
}
/// <summary>
/// One roof window of Roof Studio: where it lies on its slope and how far around it the covering stays away.
/// </summary>
/// <param name="Slope">The slope it lies on.</param>
/// <param name="Local">Its frame in the roof part: +X across the slope, +Z up the slope, -Y out of the roof.</param>
/// <param name="Width">Width of the opening across the slope.</param>
/// <param name="Length">Length of the opening up the slope.</param>
/// <param name="Keep">The area the covering leaves free, across (A) and up (T) the slope, in the slope's own measure.</param>
/// <param name="Corners">The four corners of the opening on the roof surface, counter clockwise seen from above.</param>
internal readonly record struct RoofStudioOpening( int Slope, Transform Local, float Width, float Length, (float A0, float A1, float T0, float T1) Keep, Vector3[] Corners );
internal static partial class RoofStudioCovering
{
static readonly string[] WindowOptions =
[
nameof( RoofStudioSettings.RoofWindowCount ), nameof( RoofStudioSettings.RoofWindowWidth ), nameof( RoofStudioSettings.RoofWindowLength ),
nameof( RoofStudioSettings.RoofWindowPosition ), nameof( RoofStudioSettings.RoofWindowsBothSides ), nameof( RoofStudioSettings.RoofWindowCorner ),
nameof( RoofStudioSettings.RoofWindowProfile ), nameof( RoofStudioSettings.RoofWindowDetail ), nameof( RoofStudioSettings.RoofWindowFrameWidth ),
nameof( RoofStudioSettings.RoofWindowCurb ), nameof( RoofStudioSettings.RoofWindowGlass ), nameof( RoofStudioSettings.RoofWindowGlazing ),
nameof( RoofStudioSettings.RoofWindowPanesAcross ), nameof( RoofStudioSettings.RoofWindowPanesUp ), nameof( RoofStudioSettings.RoofWindowFlashing ),
nameof( RoofStudioSettings.RoofWindowFlashingWidth ), nameof( RoofStudioSettings.RoofWindowGlassMaterial )
];
/// <summary>
/// Roof window options that do nothing: all on flat and hollow roofs, the details while windows are off.
/// </summary>
static void HideWindows( HashSet<string> hidden, RoofStudioSettings s )
{
if ( s.IsFlat || s.Hollow )
{
hidden.Add( nameof( s.RoofWindows ) );
hidden.UnionWith( WindowOptions );
return;
}
if ( !s.RoofWindows )
{
hidden.UnionWith( WindowOptions );
return;
}
if ( s.Style == StudioRoofStyle.Shed )
hidden.Add( nameof( s.RoofWindowsBothSides ) );
if ( !s.RoofWindowGlass )
hidden.UnionWith( [nameof( s.RoofWindowGlazing ), nameof( s.RoofWindowGlassMaterial )] );
if ( !s.RoofWindowFlashing )
hidden.Add( nameof( s.RoofWindowFlashingWidth ) );
}
/// <summary>
/// The measure of a slope: its lower corner, the way across along the eave and the way up the slope,
/// turned so that across times up points out of the roof.
/// </summary>
static (Vector3 Origin, Vector3 Across, Vector3 Up) Measure( Slope slope )
{
var across = (slope.B0 - slope.A0).Normal;
var rise = (slope.A1 + slope.B1) * 0.5f - (slope.A0 + slope.B0) * 0.5f;
var up = (rise - across * Vector3.Dot( rise, across )).Normal;
if ( Vector3.Cross( across, up ).z < 0.0f )
across = -across;
return (slope.A0, across, up);
}
/// <summary>
/// Where the roof windows lie: on the first slope, or on both main slopes; never on the pointed
/// ends of a hip roof, under the ridge cap or so close to an edge that the flashing would hang over it.
/// </summary>
internal static List<RoofStudioOpening> Openings( RoofStudioSettings s, float x0, float x1, float y0, float y1, float edge, float rise )
{
var list = new List<RoofStudioOpening>();
if ( s.IsFlat || s.Hollow || !s.RoofWindows )
return list;
var (slopes, turned) = Layout( s, x0, x1, y0, y1, edge, rise );
var turn = new Transform( Vector3.Zero, turned ? Rotation.FromYaw( 90.0f ) : Rotation.Identity );
var capHalf = s.RidgeCap && s.Style is StudioRoofStyle.Gable or StudioRoofStyle.Hip ? Math.Clamp( s.RidgeCapWidth, 2, 32 ) * 0.5f : 0.0f;
var bar = Math.Clamp( s.RoofWindowFrameWidth, 1, 16 );
var sheet = s.RoofWindowFlashing ? Math.Clamp( s.RoofWindowFlashingWidth, 1, 16 ) : 0.0f;
var reach = bar * 0.5f + sheet * 2.0f + 1.0f;
var count = Math.Clamp( s.RoofWindowCount, 1, 8 );
for ( var i = 0; i < slopes.Length; i++ )
{
var slope = slopes[i];
if ( slope.Pointed || i > 0 && !s.RoofWindowsBothSides )
continue;
var (origin, across, up) = Measure( slope );
var length = Vector3.Dot( slope.A1 - slope.A0, up );
if ( length < 16.0f )
continue;
float Along( Vector3 q ) => Vector3.Dot( q - origin, across );
var top = length - (capHalf > 0 ? (capHalf + 0.25f) * slope.Length / MathF.Max( slope.Run, 0.01f ) : 0.0f) - reach - 2.0f;
var bottom = reach + 2.0f;
var high = MathF.Min( Math.Clamp( s.RoofWindowLength, 8, 256 ), top - bottom );
if ( high < 8.0f )
continue;
var centre = length * Math.Clamp( s.RoofWindowPosition, 10, 90 ) / 100.0f;
var t0 = Math.Clamp( centre - high * 0.5f, bottom, top - high );
var t1 = t0 + high;
// Across the slope only where it is wide enough at the foot and at the head of the window.
var lo = float.MinValue;
var hi = float.MaxValue;
foreach ( var f in new[] { t0 / length, t1 / length } )
{
var a = Along( slope.A( f ) );
var b = Along( slope.B( f ) );
lo = MathF.Max( lo, MathF.Min( a, b ) );
hi = MathF.Min( hi, MathF.Max( a, b ) );
}
lo += 2.0f;
hi -= 2.0f;
var slot = (hi - lo) / count;
var wide = MathF.Min( Math.Clamp( s.RoofWindowWidth, 8, 256 ), slot - reach * 2.0f - 2.0f );
if ( wide < 8.0f )
continue;
for ( var k = 0; k < count; k++ )
{
var middle = lo + slot * (k + 0.5f);
var a0 = middle - wide * 0.5f;
var a1 = middle + wide * 0.5f;
var corners = new[]
{
origin + across * a0 + up * t0,
origin + across * a1 + up * t0,
origin + across * a1 + up * t1,
origin + across * a0 + up * t1
}.Select( q => turn.PointToWorld( q ) ).ToArray();
var local = turn.ToWorld( new Transform( origin + across * a0 + up * t0, Rotation.LookAt( across, up ) ) );
list.Add( new RoofStudioOpening( i, local, wide, high, (a0 - reach, a1 + reach, t0 - reach, t1 + reach), corners ) );
}
}
return list;
}
/// <summary>
/// The share of the way across a slope at a share <paramref name="f"/> up it where a point lies
/// <paramref name="along"/> across, in the measure of <see cref="Measure"/>.
/// </summary>
static float Share( Slope slope, float f, float along )
{
var (origin, across, _) = Measure( slope );
var a = Vector3.Dot( slope.A( f ) - origin, across );
var b = Vector3.Dot( slope.B( f ) - origin, across );
return MathF.Abs( b - a ) < 0.001f ? 0.0f : (along - a) / (b - a);
}
/// <summary>
/// The part of a slope from share f0 to f1 up it that roof windows keep free, as shares across it.
/// </summary>
static List<(float U0, float U1)> Free( Slope slope, IEnumerable<RoofStudioOpening> openings, float f0, float f1 )
{
var list = new List<(float, float)>();
var (_, _, up) = Measure( slope );
var length = Vector3.Dot( slope.A1 - slope.A0, up );
foreach ( var o in openings )
{
if ( o.Keep.T1 <= f0 * length || o.Keep.T0 >= f1 * length )
continue;
var shares = new[] { Share( slope, f0, o.Keep.A0 ), Share( slope, f0, o.Keep.A1 ), Share( slope, f1, o.Keep.A0 ), Share( slope, f1, o.Keep.A1 ) };
list.Add( (shares.Min(), shares.Max()) );
}
return list;
}
/// <summary>
/// A strip of a slope across from share u0 to u1, from the eave up to share <paramref name="top"/>,
/// left out where roof windows lie.
/// </summary>
static void Strip( MeshKit kit, Slope slope, IReadOnlyList<RoofStudioOpening> openings, float u0, float u1, float top, float low, float high )
{
var (_, _, up) = Measure( slope );
var length = MathF.Max( Vector3.Dot( slope.A1 - slope.A0, up ), 0.01f );
var cuts = new List<float> { u0, u1 };
foreach ( var (a, b) in Free( slope, openings, 0.0f, top ) )
{
if ( a > u0 && a < u1 ) cuts.Add( a );
if ( b > u0 && b < u1 ) cuts.Add( b );
}
cuts = [.. cuts.Distinct().OrderBy( c => c )];
Vector3 P( float f, float u ) => Vector3.Lerp( slope.A( f ), slope.B( f ), u );
for ( var k = 0; k + 1 < cuts.Count; k++ )
{
var ua = cuts[k];
var ub = cuts[k + 1];
var middle = (ua + ub) * 0.5f;
// Up the slope: from the eave to the first window over this piece, past it, and on.
var gaps = openings.Where( o => Free( slope, [o], 0.0f, top ).Any( r => middle > r.U0 && middle < r.U1 ) )
.Select( o => (F0: o.Keep.T0 / length, F1: o.Keep.T1 / length) ).OrderBy( g => g.F0 ).ToList();
var from = 0.0f;
foreach ( var (g0, g1) in gaps.Append( (top, top) ) )
{
var to = MathF.Min( g0, top );
if ( to - from > 0.002f && (P( to, ub ) - P( to, ua )).Length > 0.05f && (P( from, ub ) - P( from, ua )).Length > 0.05f )
Prism( kit, P( from, ua ), P( from, ub ), P( to, ub ), P( to, ua ), low, high );
from = MathF.Max( from, g1 );
}
}
}
}