Editor tool that generates roofs from a dragged rectangle or wall corners. Adds a Roof Studio settings panel (style, thickness, rise, overhang, fascia, parapet, coping, tiles, green roof) and builds the roof geometry in parts, with hollow shells, roof windows cut through the slope, covering and barge boards.
namespace AdvancedMappingTools;
public enum StudioRoofStyle
{
Terrace,
Green,
Gable,
Hip,
Shed,
Butterfly
}
public sealed partial class RoofStudioSettings : ArchitectureStudioSettings
{
[Property, Group( "Roof" ), WideMode]
public StudioRoofStyle Style { get; set; } = StudioRoofStyle.Terrace;
[Property, Group( "Roof" ), Range( 1, 128, slider: false ), WideMode]
public float Thickness { get; set; } = 6;
[Property, Group( "Roof" ), Range( 1, 1024, slider: false ), WideMode]
public float Rise { get; set; } = 48;
[Property, Group( "Roof" ), Range( 0, 128, slider: false ), WideMode]
public float Overhang { get; set; } = 8;
[Property, Group( "Edges" ), WideMode]
public bool Fascia { get; set; } = true;
[Property, Group( "Edges" ), Title( "Fascia Width" ), Range( .5f, 16, slider: false ), WideMode]
public float FasciaWidth { get; set; } = 2;
[Property, Group( "Parapet" ), WideMode]
public bool Parapet { get; set; } = true;
[Property, Group( "Parapet" ), Title( "Parapet Height" ), Range( 4, 128, slider: false ), WideMode]
public float ParapetHeight { get; set; } = 24;
[Property, Group( "Parapet" ), Title( "Parapet Width" ), Range( 1, 24, slider: false ), WideMode]
public float ParapetWidth { get; set; } = 4;
[Property, Group( "Finish" ), Title( "Tile Size" ), Range( 8, 128, slider: false ), WideMode]
public float TileSize { get; set; } = 32;
[Property, Group( "Finish" ), Title( "Tile Joint" ), Range( .1f, 8, slider: false ), WideMode]
public float TileJoint { get; set; } = 1;
[Property, Group( "Finish" ), Title( "Finish Thickness" ), Range( .5f, 32, slider: false ), WideMode]
public float FinishThickness { get; set; } = 2;
[Property, Group( "Coping" ), Title( "Parapet Coping" ), WideMode]
public bool Coping { get; set; }
[Property, Group( "Coping" ), Title( "Coping Thickness" ), Range( .5f, 16, slider: false ), WideMode]
public float CopingThickness { get; set; } = 2;
[Property, Group( "Coping" ), Title( "Coping Overhang" ), Range( 0, 8, slider: false ), WideMode]
public float CopingOverhang { get; set; } = 1;
public bool IsFlat => Style is StudioRoofStyle.Terrace or StudioRoofStyle.Green;
public override IReadOnlySet<string> UnusedOptions
{
get
{
var hidden = new HashSet<string>( base.UnusedOptions );
StudioExtras.HideRoof( hidden, this );
if ( IsFlat )
hidden.Add( nameof( Rise ) );
else
foreach ( var n in new[]
{
nameof( Parapet ),
nameof( ParapetHeight ),
nameof( ParapetWidth ),
nameof( TileSize ),
nameof( TileJoint ),
nameof( FinishThickness )
}
)
hidden.Add( n );
if ( !Parapet )
{
hidden.Add( nameof( ParapetHeight ) );
hidden.Add( nameof( ParapetWidth ) );
}
if ( !IsFlat || !Parapet )
hidden.Add( nameof( Coping ) );
if ( !IsFlat || !Parapet || !Coping )
hidden.UnionWith( [nameof( CopingThickness ), nameof( CopingOverhang )] );
if ( Style != StudioRoofStyle.Terrace )
{
hidden.Add( nameof( TileSize ) );
hidden.Add( nameof( TileJoint ) );
}
if ( !Fascia )
hidden.Add( nameof( FasciaWidth ) );
if ( !IsFlat && !(Hollow && BottomClosure) )
hidden.Add( nameof( FinishMaterial ) );
if ( !Fascia && (!IsFlat || !Parapet) && !(Hollow && !IsFlat && GableEnds && Style != StudioRoofStyle.Hip) )
hidden.Add( nameof( DetailMaterial ) );
RoofStudioCovering.Hide( hidden, this );
return hidden;
}
}
}
public sealed class RoofStudioGenerator : IPartGenerator
{
public string Id => "roof_studio";
public string Title => "Roof Studio";
public Type SettingsType => typeof(RoofStudioSettings);
public PartPlan Generate( GeneratorSettings settings, GenContext context )
{
var s = (RoofStudioSettings)settings;
var p = new ArchitectureStudioPlan( context, s, Title );
var over = s.EaveOverhang;
var end = s.EndOverhang;
var w = MathF.Max( s.Width, 16 ) + 2 * end;
var d = MathF.Max( s.Depth, 16 ) + 2 * over;
var t = Math.Clamp( s.Thickness, 1, 128 );
var finish = Math.Clamp( s.FinishThickness, .5f, 32 );
var x = -end;
var y = -over;
var shape = s.Style switch
{
StudioRoofStyle.Gable => RoofShape.Gable,
StudioRoofStyle.Hip => RoofShape.Hip,
StudioRoofStyle.Shed => RoofShape.Shed,
StudioRoofStyle.Butterfly => RoofShape.Butterfly,
_ => RoofShape.Flat
};
if ( s.Hollow && !s.IsFlat )
StudioExtras.RoofShell( p, s, context, t );
else
{
var openings = RoofStudioCovering.Openings( s, x, x + w, y, y + d, t, Math.Clamp( s.Rise, 1, 1024 ) );
p.Add( "roof", "Roof Structure", p.Structure, kit =>
{
if ( openings.Count == 0 )
{
RoofEditor.BuildRoof( kit, kit.Frame, x, x + w, y, y + d, shape, Math.Clamp( s.Rise, 1, 1024 ), t, p.Structure, p.Structure );
return;
}
// Roof windows: a real opening through the roof, cut straight down from the outline on the slope.
var body = new MeshKit { Frame = kit.Frame, Material = p.Structure, LiftFlushFaces = false };
RoofEditor.BuildRoof( body, kit.Frame, x, x + w, y, y + d, shape, Math.Clamp( s.Rise, 1, 1024 ), t, p.Structure, p.Structure );
var mesh = body.Finish();
foreach ( var o in openings )
{
var cutter = new MeshKit { Frame = kit.Frame, Material = p.Structure };
var c = o.Corners;
var below = -4096.0f;
var above = t + Math.Clamp( s.Rise, 1, 1024 ) + 4096.0f;
cutter.Hex( c[0].WithZ( below ), c[1].WithZ( below ), c[2].WithZ( below ), c[3].WithZ( below ), c[0].WithZ( above ), c[1].WithZ( above ), c[2].WithZ( above ), c[3].WithZ( above ) );
mesh = MeshCutter.SubtractClean( mesh, cutter.Finish(), Transform.Zero, p.Structure );
}
kit.ImportMesh( mesh, Transform.Zero );
} );
RoofWindows( p, s, context, openings );
}
RoofStudioCovering.Build( p, s, x, x + w, y, y + d, t, Math.Clamp( s.Rise, 1, 1024 ) );
if ( s.Fascia && s.Hollow && !s.IsFlat )
BargeBoards( p, Math.Clamp( s.FasciaWidth, .5f, 16 ) );
else if ( s.Fascia )
{
var a = Math.Clamp( s.FasciaWidth, .5f, 16 );
p.Box( "fascia_front", "Front Fascia", p.Detail, new( x - a, y - a, 0 ), new( x + w + a, y, t ) );
p.Box( "fascia_back", "Back Fascia", p.Detail, new( x - a, y + d, 0 ), new( x + w + a, y + d + a, t ) );
p.Box( "fascia_left", "Left Fascia", p.Detail, new( x - a, y, 0 ), new( x, y + d, t ) );
p.Box( "fascia_right", "Right Fascia", p.Detail, new( x + w, y, 0 ), new( x + w + a, y + d, t ) );
}
if ( !s.IsFlat )
return p.Root;
var edge = s.Parapet ? MathF.Min( Math.Clamp( s.ParapetWidth, 1, 24 ), MathF.Min( w, d ) * .2f ) : 0;
if ( s.Parapet )
{
var h = MathF.Max( Math.Clamp( s.ParapetHeight, 4, 128 ), finish + 1 );
p.Box( "parapet_front", "Front Parapet", p.Detail, new( x, y, t ), new( x + w, y + edge, t + h ) );
p.Box( "parapet_back", "Back Parapet", p.Detail, new( x, y + d - edge, t ), new( x + w, y + d, t + h ) );
p.Box( "parapet_left", "Left Parapet", p.Detail, new( x, y + edge, t ), new( x + edge, y + d - edge, t + h ) );
p.Box( "parapet_right", "Right Parapet", p.Detail, new( x + w - edge, y + edge, t ), new( x + w, y + d - edge, t + h ) );
if ( s.Coping )
{
var c = Math.Clamp( s.CopingThickness, .5f, 16 );
var lip = MathF.Min( Math.Clamp( s.CopingOverhang, 0, 8 ), MathF.Min( w - 2 * edge, d - 2 * edge ) * .2f );
var z = t + h;
p.Box( "coping_front", "Front Coping", p.Detail, new( x - lip, y - lip, z ), new( x + w + lip, y + edge + lip, z + c ) );
p.Box( "coping_back", "Back Coping", p.Detail, new( x - lip, y + d - edge - lip, z ), new( x + w + lip, y + d + lip, z + c ) );
p.Box( "coping_left", "Left Coping", p.Detail, new( x - lip, y + edge + lip, z ), new( x + edge + lip, y + d - edge - lip, z + c ) );
p.Box( "coping_right", "Right Coping", p.Detail, new( x + w - edge - lip, y + edge + lip, z ), new( x + w + lip, y + d - edge - lip, z + c ) );
}
}
if ( s.Style == StudioRoofStyle.Green )
{
p.Box( "green_roof", "Planting / Substrate", p.Finish, new( x + edge, y + edge, t ), new( x + w - edge, y + d - edge, t + finish ) );
RoofStudioCovering.Plants( p, s, context, x + edge + 2, x + w - edge - 2, y + edge + 2, y + d - edge - 2, t + finish );
}
else
{
var nx = Math.Clamp( (int)MathF.Ceiling( (w - 2 * edge) / Math.Clamp( s.TileSize, 8, 128 ) ), 1, 32 );
var ny = Math.Clamp( (int)MathF.Ceiling( (d - 2 * edge) / Math.Clamp( s.TileSize, 8, 128 ) ), 1, 32 );
var sx = (w - 2 * edge) / nx;
var sy = (d - 2 * edge) / ny;
var joint = MathF.Min( Math.Clamp( s.TileJoint, .1f, 8 ), MathF.Min( sx, sy ) * .25f );
p.Add( "pavers", "Terrace Pavers", p.Finish, kit =>
{
for ( var i = 0; i < nx; i++ )
for ( var j = 0; j < ny; j++ )
kit.Box( new( x + edge + i * sx + joint * .5f, y + edge + j * sy + joint * .5f, t ), new( x + edge + (i + 1) * sx - joint * .5f, y + edge + (j + 1) * sy - joint * .5f, t + finish ) );
} );
}
return p.Root;
}
/// <summary>
/// The roof windows themselves: a profiled frame standing out of the covering, glass and flashing.
/// </summary>
static void RoofWindows( ArchitectureStudioPlan p, RoofStudioSettings s, GenContext context, List<RoofStudioOpening> openings )
{
var covering = s.Covering == RoofCovering.None ? 0.0f : Math.Clamp( s.CoveringThickness, 0.5f, 8 ) * 1.6f;
var glass = context.Material( s.RoofWindowGlassMaterial );
for ( var n = 0; n < openings.Count; n++ )
{
var o = openings[n];
p.Add( $"roof_window_{n + 1}", $"Roof Window {n + 1}", p.Detail, kit =>
{
kit.Frame = kit.Frame.ToWorld( o.Local );
var bar = MathF.Min( Math.Clamp( s.RoofWindowFrameWidth, 1, 16 ), MathF.Min( o.Width, o.Length ) * 0.2f );
var lap = bar * 0.5f;
var curb = MathF.Max( Math.Clamp( s.RoofWindowCurb, 1, 16 ), covering + 1.0f );
WindowKit.Build( kit, -lap, o.Width + lap, -lap, o.Length + lap, -curb, new WindowSpec
{
Width = bar,
Depth = curb,
Edge = MathF.Min( 0.75f, bar * 0.3f ),
Corner = s.RoofWindowCorner,
Profile = s.RoofWindowProfile,
Detail = s.RoofWindowDetail,
Glass = s.RoofWindowGlass,
Glazing = s.RoofWindowGlazing,
Columns = Math.Clamp( s.RoofWindowPanesAcross, 1, 6 ),
Rows = Math.Clamp( s.RoofWindowPanesUp, 1, 6 ),
FrameMaterial = p.Detail,
GlassMaterial = glass,
DetailMaterial = p.Structure
} );
if ( s.RoofWindowFlashing )
{
var sheet = Math.Clamp( s.RoofWindowFlashingWidth, 1, 16 );
kit.Material = p.Detail;
WindowKit.Flashing( kit, -lap, o.Width + lap, -lap, o.Length + lap, 0, sheet, sheet, MathF.Min( curb * 0.6f, 2 ) );
}
} );
}
}
/// <summary>
/// Boards on the outer edges of the panels of a hollow roof: each outer end face of a panel, at the
/// eaves and along the gables, pushed outward by the fascia width. So the boards follow the slope
/// of every roof shape instead of lying level at the foot of the roof.
/// </summary>
static void BargeBoards( ArchitectureStudioPlan p, float width )
{
var faces = new List<Vector3[]>();
var all = new List<Vector3>();
void Visit( PartPlan part, Transform parent )
{
var world = parent.ToWorld( part.Local );
if ( part.Mesh is not null && part.Kind == "RoofPanel" )
{
var mesh = part.Mesh;
var center = mesh.GetVertexPositions().Aggregate( Vector3.Zero, ( a, b ) => a + b ) / MathF.Max( mesh.GetVertexPositions().Count(), 1 );
foreach ( var face in mesh.FaceHandles )
{
var ring = mesh.GetFaceVertices( face ).Select( v => mesh.GetVertexPosition( v ) ).ToArray();
if ( ring.Length != 4 )
continue;
var middle = (ring[0] + ring[1] + ring[2] + ring[3]) / 4;
var normal = Vector3.Cross( ring[1] - ring[0], ring[2] - ring[0] ).Normal;
if ( normal.Dot( middle - center ) < 0 )
{
Array.Reverse( ring );
normal = -normal;
}
if ( MathF.Abs( normal.z ) > .3f )
continue;
faces.Add( [.. ring.Select( v => world.PointToWorld( v ) )] );
}
all.AddRange( mesh.GetVertexPositions().Select( v => world.PointToWorld( v ) ) );
}
foreach ( var child in part.Children )
Visit( child, world );
}
foreach ( var child in p.Root.Children )
Visit( child, new Transform( Vector3.Zero, Rotation.Identity ) );
if ( all.Count == 0 )
return;
var box = BBox.FromPoints( all );
bool OnEdge( Vector3[] ring )
{
var middle = (ring[0] + ring[1] + ring[2] + ring[3]) / 4;
return MathF.Abs( middle.x - box.Mins.x ) < .05f || MathF.Abs( middle.x - box.Maxs.x ) < .05f || MathF.Abs( middle.y - box.Mins.y ) < .05f || MathF.Abs( middle.y - box.Maxs.y ) < .05f;
}
var edges = faces.Where( OnEdge ).ToList();
if ( edges.Count == 0 )
return;
p.Add( "barge_boards", "Barge Boards and Fascia", p.Detail, kit =>
{
foreach ( var ring in edges )
{
var normal = Vector3.Cross( ring[1] - ring[0], ring[2] - ring[0] ).Normal * width;
kit.Hex( ring[0], ring[1], ring[2], ring[3], ring[0] + normal, ring[1] + normal, ring[2] + normal, ring[3] + normal );
}
} );
}
}
[ToolCategory( "Roofs" ), Title( "Roof Studio" ), Icon( "roofing" )]
public sealed class RoofStudioEditor( PrimitiveTool tool ) : PartBuildingEditor( tool )
{
readonly RoofStudioGenerator generator = new();
public RoofStudioSettings Settings { get; } = new();
protected override IPartGenerator Generator => generator;
protected override GeneratorSettings Parts => Settings;
public override bool ChangesOtherSettings( string property ) => property is nameof( RoofStudioSettings.Hollow ) or nameof( RoofStudioSettings.BottomClosure ) or nameof( RoofStudioSettings.Style ) or nameof( RoofStudioSettings.Parapet ) or nameof( RoofStudioSettings.Fascia ) or nameof( RoofStudioSettings.Coping ) or nameof( RoofStudioSettings.Covering ) or nameof( RoofStudioSettings.RidgeCap ) or nameof( RoofStudioSettings.Plants ) or nameof( RoofStudioSettings.GableEnds ) or nameof( RoofStudioSettings.GableThickness ) or nameof( RoofStudioSettings.RoofWindows ) or nameof( RoofStudioSettings.RoofWindowGlass ) or nameof( RoofStudioSettings.RoofWindowFlashing );
protected override void Prepare()
{
// On walls the roof covers them to their outer faces: the clicks are on the wall lines.
var margin = MathF.Max( Settings.WallMargin, 0 );
Settings.Width = RectLength + margin * 2;
Settings.Depth = RectDepth + margin * 2;
Settings.StructureMaterial = PartTool.Pick( Settings.StructureMaterial, SurfaceMaterial );
Settings.DetailMaterial = PartTool.Pick( Settings.DetailMaterial, TrimMaterial );
Settings.FinishMaterial = PartTool.Pick( Settings.FinishMaterial, SurfaceMaterial );
}
protected override Transform PlacementFrame => RectFrame.ToWorld( new Transform( new Vector3( -MathF.Max( Settings.WallMargin, 0 ), -MathF.Max( Settings.WallMargin, 0 ), 0 ) ) );
protected override IEnumerable<string> Hints => [.. base.Hints, "Click on a wall corner: the roof sits on the walls"];
protected override Vector3? UnderCursor( Vector3 point ) => WallCornerUnderCursor( point, 16 ) ?? base.UnderCursor( point );
/// <summary>A point near the top corner of a finished wall lands on it, so the roof sits on the walls.</summary>
protected override Vector3 Dock( Vector3 point )
{
var docked = base.Dock( point );
if ( !ConnectFinished || SceneEditorSession.Active is null )
return docked;
var corner = WallTops.Nearest( SceneEditorSession.Active.Scene, docked, DockRadius + 16 );
if ( Points.Count == 0 )
Settings.WallMargin = corner?.Half ?? 0;
if ( corner is not { } found )
return docked;
Settings.WallMargin = MathF.Max( Settings.WallMargin, found.Half );
DrawLabel( found.Top, "On the wall", Color.Green );
return found.Top;
}
}