editor/AdvancedMappingTools/Procedural/HouseGenerator.cs

Settings class and part generator for the Advanced Mapping Tools procedurally generated house. It lays out a foundation, per storey walls with window and door openings, a staircase, ceiling slabs with a stair opening, and a roof, handing the actual geometry of walls, frames, doors, stairs and roofs to the other generators.

namespace AdvancedMappingTools;

public enum HouseSide
{
	Front,
	Right,
	Back,
	Left
}

public enum HouseShutters
{
	None,
	[Title( "Plain Panels" )] Panel,
	[Title( "Louvred" )] Louvred
}

public sealed class HouseSettings : GeneratorSettings, IProfiledFrameSettings
{
	public float Width { get; set; } = 384.0f;
	public float Depth { get; set; } = 288.0f;

	[Property, Group( "House" ), Range( 1, 32, slider: false ), WideMode]
	public int Storeys { get; set; } = 2;

	[Property, Group( "House" ), Title( "Storey Height" ), Range( 64, 1024, slider: false ), WideMode]
	public float StoreyHeight { get; set; } = 128.0f;

	[Property, Group( "House" ), Title( "Wall Thickness" ), Range( 1, 64, slider: false ), WideMode]
	public float WallThickness { get; set; } = 8.0f;

	[Property, Group( "House" ), Title( "Slab Thickness" ), Range( 1, 64, slider: false ), WideMode]
	public float SlabThickness { get; set; } = 8.0f;

	[Property, Group( "House" ), Range( 0, 256, slider: false ), WideMode]
	public float Foundation { get; set; } = 8.0f;

	[Property, Group( "Parts" ), Title( "Ground Floor" ), WideMode, Description( "A floor surface inside the walls of the lowest storey." )]
	public bool GroundFloor { get; set; } = true;

	[Property, Group( "Parts" ), WideMode, Description( "Slabs on top of each storey." )]
	public bool Ceilings { get; set; } = true;

	[Property, Group( "Parts" ), Title( "Roof On Top" ), WideMode]
	public bool Roof { get; set; } = true;

	[Property, Group( "Sides" ), Title( "Front Windows" ), WideMode]
	public bool WindowsFront { get; set; } = true;

	[Property, Group( "Sides" ), Title( "Right Windows" ), WideMode]
	public bool WindowsRight { get; set; } = true;

	[Property, Group( "Sides" ), Title( "Back Windows" ), WideMode]
	public bool WindowsBack { get; set; } = true;

	[Property, Group( "Sides" ), Title( "Left Windows" ), WideMode]
	public bool WindowsLeft { get; set; } = true;

	[Property, Group( "Sides" ), Title( "Door Side" ), WideMode]
	public HouseSide DoorSide { get; set; } = HouseSide.Front;

	[Property, Group( "Windows" ), Title( "Window Spacing" ), Range( 32, 1024, slider: false ), WideMode]
	public float WindowSpacing { get; set; } = 112.0f;

	[Property, Group( "Windows" ), Title( "Window Width" ), Range( 8, 512, slider: false ), WideMode]
	public float WindowWidth { get; set; } = 48.0f;

	[Property, Group( "Windows" ), Title( "Window Height" ), Range( 8, 512, slider: false ), WideMode]
	public float WindowHeight { get; set; } = 56.0f;

	[Property, Group( "Windows" ), Title( "Sill Height" ), Range( 1, 512, slider: false ), WideMode]
	public float SillHeight { get; set; } = 36.0f;

	[Property, Group( "Windows" ), Title( "Frame Style" ), WideMode]
	public FrameStyle FrameStyle { get; set; } = FrameStyle.Wooden;

	[Property, Group( "Windows" ), Title( "Bar Width" ), Range( 0.5f, 32, slider: false ), WideMode]
	public float BarWidth { get; set; } = 3.0f;

	[Property, Group( "Windows" ), Title( "Vertical Mullions" ), Range( 0, 8, slider: false ), WideMode]
	public int VerticalMullions { get; set; } = 1;

	[Property, Group( "Windows" ), Title( "Horizontal Mullions" ), Range( 0, 8, slider: false ), WideMode]
	public int HorizontalMullions { get; set; } = 1;

	[Property, Group( "Windows" ), Title( "Broken Glass" ), Range( 0, 1 ), WideMode]
	public float BrokenGlass { get; set; } = 0.0f;

	[Property, Group( "Windows" ), Title( "Profiled Frames" ), WideMode, Description( "Profiled frames with real corner joints, glass seated in them and glazing bars. Off: the plain bars of earlier versions." )]
	public bool ProfiledFrames { get; set; }

	[Property, ShowIf( nameof( ProfiledFrames ), true ), Group( "Windows" ), Title( "Frame Corners" ), WideMode]
	public FrameCorner FrameCorner { get; set; } = FrameCorner.Mitred;

	[Property, ShowIf( nameof( ProfiledFrames ), true ), Group( "Windows" ), Title( "Frame Profile" ), WideMode]
	public FrameProfile FrameProfile { get; set; } = FrameProfile.Chamfered;

	[Property, ShowIf( nameof( ProfiledFrames ), true ), Group( "Windows" ), Title( "Frame Detail" ), WideMode]
	public WindowDetail FrameDetail { get; set; } = WindowDetail.Standard;

	[Property, ShowIf( nameof( ProfiledFrames ), true ), Group( "Windows" ), Title( "Glazing" ), WideMode]
	public Glazing FrameGlazing { get; set; } = Glazing.Double;

	[Property, Group( "Door" ), Title( "Door Width" ), Range( 16, 256, slider: false ), WideMode]
	public float DoorWidth { get; set; } = 56.0f;

	[Property, Group( "Door" ), Title( "Door Height" ), Range( 32, 512, slider: false ), WideMode]
	public float DoorHeight { get; set; } = 96.0f;

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

	[Property, ShowIf( nameof( Stairs ), true ), Group( "Stairs" ), Title( "Stair Width" ), Range( 16, 256, slider: false ), WideMode]
	public float StairWidth { get; set; } = 48.0f;

	[Property, ShowIf( nameof( Stairs ), true ), Group( "Stairs" ), Title( "Step Depth" ), Range( 4, 64, slider: false ), WideMode]
	public float StepDepth { get; set; } = 10.0f;

	[Property, ShowIf( nameof( Stairs ), true ), Group( "Stairs" ), Title( "Broken Steps" ), Range( 0, 1 ), WideMode]
	public float BrokenSteps { get; set; } = 0.0f;

	[Property, ShowIf( nameof( Roof ), true ), Group( "Roof" ), Title( "Roof Shape" ), WideMode]
	public HouseRoof RoofShape { get; set; } = HouseRoof.Gable;

	[Property, ShowIf( nameof( Roof ), true ), Group( "Roof" ), Title( "Ridge Height" ), Range( 8, 1024, slider: false ), WideMode]
	public float RidgeHeight { get; set; } = 96.0f;

	[Property, ShowIf( nameof( Roof ), true ), Group( "Roof" ), Range( 0, 128, slider: false ), WideMode]
	public float Overhang { get; set; } = 16.0f;

	[Property, ShowIf( nameof( Roof ), true ), Group( "Roof" ), Title( "Roof Thickness" ), Range( 1, 32, slider: false ), WideMode]
	public float RoofThickness { get; set; } = 4.0f;

	[Property, ShowIf( nameof( Roof ), true ), Group( "Roof" ), Title( "Rafter Spacing" ), Range( 8, 256, slider: false ), WideMode]
	public float RafterSpacing { get; set; } = 48.0f;

	[Property, ShowIf( nameof( Roof ), true ), Group( "Roof" ), WideMode]
	public bool Gutters { get; set; } = true;

	[Property, ShowIf( nameof( Roof ), true ), Group( "Roof" ), Title( "Ridge Cap" ), WideMode]
	public bool RidgeCap { get; set; } = true;

	[Property, ShowIf( nameof( Roof ), true ), Group( "Roof" ), WideMode]
	public bool Fascia { get; set; } = false;

	[Property, Group( "Materials" ), Title( "Walls" ), ResourceType( "vmat" ), WideMode]
	public string WallMaterial { get; set; }

	[Property, Group( "Materials" ), Title( "Trim" ), ResourceType( "vmat" ), WideMode]
	public string TrimMaterial { get; set; }

	[Property, Group( "Materials" ), Title( "Floors" ), ResourceType( "vmat" ), WideMode]
	public string FloorMaterial { get; set; }

	[Property, ShowIf( nameof( Roof ), true ), Group( "Materials" ), Title( "Roof" ), ResourceType( "vmat" ), WideMode]
	public string RoofMaterial { get; set; }

	[Property, Group( "Materials" ), Title( "Glass" ), ResourceType( "vmat" ), WideMode]
	public string GlassMaterial { get; set; }

	[Property, Group( "Windows" ), WideMode, Description( "Two leaves beside every window, open flat against the wall." )]
	public HouseShutters Shutters { get; set; } = HouseShutters.None;

	[Property, Group( "Windows" ), Title( "Window Boxes" ), WideMode, Description( "A flower box under every window." )]
	public bool WindowBoxes { get; set; }

	[Property, Group( "Door" ), Title( "Door Canopy" ), WideMode, Description( "A small roof over the door." )]
	public bool DoorCanopy { get; set; }

	[Property, Group( "Door" ), Title( "Door Steps" ), WideMode, Description( "Steps from the ground up to the door, as many as the foundation needs." )]
	public bool DoorSteps { get; set; }
}

/// <summary>
/// A whole house, put together only from the requests of the other generators.
/// It decides where things go, what they look like is theirs to decide.
/// </summary>
public sealed class HouseGenerator : IPartGenerator
{
	const string FloorCategory = "floor";
	const string DoorCategory = "door";

	static readonly string[] SideNames = ["front", "right", "back", "left"];

	public string Id => "house";
	public string Title => "House";
	public Type SettingsType => typeof( HouseSettings );

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (HouseSettings)settings;
		var root = context.Group( "root", "House", "Building", "building" );
		var floorMaterial = context.Material( s.FloorMaterial );

		root.Add( context.Solid( "foundation", "Foundation", "Foundation", FloorCategory, floorMaterial,
			kit => kit.Box( new Vector3( -2.0f, -2.0f, 0.0f ), new Vector3( s.Width + 2.0f, s.Depth + 2.0f, s.Foundation ) ) ) );

		if ( s.GroundFloor )
		{
			var t = s.WallThickness;

			root.Add( context.Solid( "ground_floor", "Ground Floor", "FloorSurface", FloorCategory, floorMaterial,
				kit => kit.Box( new Vector3( t, t, s.Foundation ), new Vector3( s.Width - t, s.Depth - t, s.Foundation + 1.0f ) ) ) );
		}

		var stairRise = s.StoreyHeight + s.SlabThickness;
		var steps = Math.Max( (int)MathF.Round( stairRise / 7.5f ), 2 );
		var run = MathF.Min( steps * s.StepDepth, s.Width - s.WallThickness * 2.0f - 8.0f );
		var stairX = s.Width - s.WallThickness - run - 4.0f;
		var stairY = s.Depth - s.WallThickness - s.StairWidth * 0.5f - 2.0f;

		for ( var storey = 0; storey < s.Storeys; storey++ )
		{
			var id = $"floor{storey}";
			var height = s.Foundation + storey * stairRise;
			var floor = root.Add( context.Group( id, $"Floor {storey + 1}", "Floor", "building" ).At( Vector3.Up * height ) );
			var hasStairs = s.Stairs && storey < s.Storeys - 1;

			for ( var side = 0; side < SideNames.Length; side++ )
				floor.Add( BuildSide( s, context, id, side, storey ) );

			var hole = hasStairs
				? new Rect3( stairX - 2.0f, stairX + run + 2.0f, s.Depth - s.WallThickness - s.StairWidth - 4.0f, s.Depth - s.WallThickness )
				: default( Rect3? );

			if ( s.Ceilings )
				floor.Add( BuildSlab( s, context, $"{id}/ceiling", floorMaterial, hole ) );

			if ( !hasStairs ) continue;

			var stairs = StairGenerator.Generate( new StairRequest( $"{id}/stairs", "Stairs", run, s.StairWidth, stairRise, steps, 2.0f,
				true, true, 36.0f, s.BrokenSteps, s.Variation, floorMaterial, context.Material( s.TrimMaterial ) ), context );

			floor.Add( stairs.At( new Vector3( stairX, stairY, 0.0f ) ) );
		}

		if ( !s.Roof ) return root;

		var top = s.Foundation + s.Storeys * stairRise;

		root.Add( RoofGenerator.Generate( new RoofRequest( "roof", "Roof", s.Width, s.Depth, s.RidgeHeight, s.Overhang, s.RoofThickness,
			s.RoofShape, s.RafterSpacing, s.Gutters, s.Variation, context.Material( s.RoofMaterial ), context.Material( s.TrimMaterial ), s.RidgeCap, s.Fascia ), context ).At( Vector3.Up * top ) );

		return root;
	}

	readonly record struct Rect3( float X0, float X1, float Y0, float Y1 );

	/// <summary>
	/// Shutters and a flower box on the outer face of the wall, beside and under a window. They touch
	/// the wall only; a leaf that would reach the door or past the wall end is left out.
	/// </summary>
	static void WindowDetails( HouseSettings s, GenContext context, PartPlan group, string id, float center, float width, float slot, float length, float half, float door, Material trim )
	{
		var outside = -half;
		if ( s.Shutters != HouseShutters.None )
		{
			var leaf = MathF.Min( width * 0.5f, (slot - width) * 0.5f - 2.0f );
			if ( leaf >= 6.0f )
			{
				foreach ( var (x0, x1, name) in new[] { (center - width * 0.5f - leaf, center - width * 0.5f, "left"), (center + width * 0.5f, center + width * 0.5f + leaf, "right") } )
				{
					if ( x0 < 2.0f || x1 > length - 2.0f )
						continue;
					if ( door >= 0 && x1 > door - s.DoorWidth * 0.5f - 2.0f && x0 < door + s.DoorWidth * 0.5f + 2.0f )
						continue;
					var (a, b) = (x0, x1);
					group.Add( context.Solid( $"{id}/shutter_{name}", "Shutter", "Shutter", "building", trim, kit =>
					{
						var z0 = s.SillHeight;
						var z1 = s.SillHeight + s.WindowHeight;
						if ( s.Shutters == HouseShutters.Panel )
						{
							kit.Box( new Vector3( a, outside - 1.5f, z0 ), new Vector3( b, outside, z1 ) );
							return;
						}

						// A frame round the leaf and slats across it, the slats a little back. The slats sit
						// flush on the frame; lifting flush faces apart would push the leaf into the wall.
						kit.LiftFlushFaces = false;
						const float rim = 1.5f;
						kit.Box( new Vector3( a, outside - 1.5f, z0 ), new Vector3( b, outside, z0 + rim ) );
						kit.Box( new Vector3( a, outside - 1.5f, z1 - rim ), new Vector3( b, outside, z1 ) );
						kit.Box( new Vector3( a, outside - 1.5f, z0 + rim ), new Vector3( a + rim, outside, z1 - rim ) );
						kit.Box( new Vector3( b - rim, outside - 1.5f, z0 + rim ), new Vector3( b, outside, z1 - rim ) );
						var slats = Math.Max( 2, (int)MathF.Floor( (z1 - z0 - rim * 2) / 4.0f ) );
						var step = (z1 - z0 - rim * 2) / slats;
						for ( var k = 0; k < slats; k++ )
						{
							var bottom = z0 + rim + step * k + step * 0.2f;
							kit.Box( new Vector3( a + rim, outside - 1.0f, bottom ), new Vector3( b - rim, outside, bottom + step * 0.6f ) );
						}
					} ) );
				}
			}
		}

		// The window sill hangs 2 below the opening; box and plants stay under it.
		var sillBottom = s.SillHeight - 2.0f;
		if ( s.WindowBoxes && sillBottom > 16.0f )
		{
			var top = sillBottom - 3.5f;
			group.Add( context.Solid( $"{id}/flower_box", "Window Box", "WindowBox", "building", trim, kit =>
			{
				kit.Box( new Vector3( center - width * 0.5f, outside - 8.0f, top - 8.0f ), new Vector3( center + width * 0.5f, outside, top ) );
				// Plants: low mounds along the box.
				var count = Math.Max( 2, (int)MathF.Floor( width / 10.0f ) );
				for ( var k = 0; k < count; k++ )
				{
					var x = center - width * 0.5f + width * (k + 0.5f) / count;
					kit.Lathe( new Vector3( x, outside - 4.0f, top ), [new( 0, 0 ), new( 3.5f, 0 ), new( 3.0f, 1.5f ), new( 1.5f, 2.6f ), new( 0, 3.0f )], 8 );
				}
			} ) );
		}
	}

	static PartPlan BuildSide( HouseSettings s, GenContext context, string floorId, int side, int storey )
	{
		var t = s.WallThickness;
		var half = t * 0.5f;

		// Walls run counter clockwise with the inside on their left. The short walls fit between the long ones.
		(Vector3 Corner, float Yaw, float Length)[] sides =
		[
			(new Vector3( 0.0f, half, 0.0f ), 0.0f, s.Width),
			(new Vector3( s.Width - half, t, 0.0f ), 90.0f, s.Depth - t * 2.0f),
			(new Vector3( s.Width, s.Depth - half, 0.0f ), 180.0f, s.Width),
			(new Vector3( half, s.Depth - t, 0.0f ), 270.0f, s.Depth - t * 2.0f)
		];

		var (corner, yaw, length) = sides[side];
		var name = SideNames[side];
		var id = $"{floorId}/{name}";
		var label = char.ToUpperInvariant( name[0] ) + name[1..];
		var group = context.Group( id, $"{label} Side", "Side", "building" ).At( corner, yaw );

		var trim = context.Material( s.TrimMaterial );
		var glass = context.Material( s.GlassMaterial );
		var openings = new List<WallOpening>();
		var hasDoor = storey == 0 && side == (int)s.DoorSide;
		var hasWindows = side switch
		{
			0 => s.WindowsFront,
			1 => s.WindowsRight,
			2 => s.WindowsBack,
			_ => s.WindowsLeft
		};
		var slots = hasWindows ? Math.Max( (int)MathF.Floor( length / MathF.Max( s.WindowSpacing, 32.0f ) ), 1 ) : 0;
		var width = slots > 0 ? MathF.Min( s.WindowWidth, length / slots - 8.0f ) : 0.0f;
		var windowIndex = 0;

		if ( hasDoor )
		{
			var center = length * 0.5f;

			openings.Add( new WallOpening( center - s.DoorWidth * 0.5f, center + s.DoorWidth * 0.5f, 0.0f, s.DoorHeight ) );

			group.Add( DoorGenerator.Generate( new DoorRequest( $"{id}/door", "Door", s.DoorWidth, s.DoorHeight, t + 2.0f, s.BarWidth, trim, trim ), context )
				.At( new Vector3( center, 0.0f, 0.0f ) ) );

			var outside = -half;
			if ( s.DoorCanopy )
			{
				// Above the door frame, below the next storey.
				var z = MathF.Min( s.DoorHeight + 6.0f, s.StoreyHeight - 4.0f );
				group.Add( context.Solid( $"{id}/canopy", "Door Canopy", "DoorCanopy", "building", trim,
					kit => kit.Box( new Vector3( center - s.DoorWidth * 0.5f - 8.0f, outside - 24.0f, z ), new Vector3( center + s.DoorWidth * 0.5f + 8.0f, outside, z + 3.0f ) ) ) );
			}

			if ( s.DoorSteps && s.Foundation > 0.5f )
			{
				// From the floor down to the ground, in front of the foundation, which stands 2 out.
				var count = Math.Max( 1, (int)MathF.Ceiling( s.Foundation / 8.0f ) );
				var rise = s.Foundation / count;
				var front = outside - 2.0f;
				group.Add( context.Solid( $"{id}/steps", "Door Steps", "DoorSteps", "building", trim, kit =>
				{
					for ( var k = 0; k < count; k++ )
					{
						var top = -rise * k;
						kit.Box( new Vector3( center - s.DoorWidth * 0.5f - 4.0f, front - 10.0f * (k + 1), -s.Foundation ), new Vector3( center + s.DoorWidth * 0.5f + 4.0f, front - 10.0f * k, top ) );
					}
				} ) );
			}
		}

		for ( var slot = 0; slot < slots; slot++ )
		{
			var center = length * (slot + 0.5f) / slots;

			// The door takes the place of any window it would run into.
			if ( hasDoor && MathF.Abs( center - length * 0.5f ) < (s.DoorWidth + width) * 0.5f + 8.0f ) continue;

			openings.Add( new WallOpening( center - width * 0.5f, center + width * 0.5f, s.SillHeight, s.SillHeight + s.WindowHeight ) );

			var window = FrameGenerator.Generate( new FrameRequest( $"{id}/window{windowIndex}", $"Window {windowIndex + 1}",
				width, s.WindowHeight, t + 2.0f, s.BarWidth, s.FrameStyle, s.VerticalMullions, s.HorizontalMullions,
				true, true, s.BrokenGlass, s.Variation, trim, glass, FrameGenerator.Spec( s ) ), context );

			group.Add( window.At( new Vector3( center, 0.0f, s.SillHeight ) ) );
			WindowDetails( s, context, group, $"{id}/window{windowIndex}", center, width, length / slots, length, half, hasDoor ? length * 0.5f : -1.0f, trim );
			windowIndex++;
		}

		// Without a ceiling the wall has to reach up to the next storey itself.
		var height = s.Ceilings ? s.StoreyHeight : s.StoreyHeight + s.SlabThickness;

		group.Add( WallGenerator.Generate( new WallRequest( $"{id}/wall", "Wall", length, height, t, openings, s.Variation, context.Material( s.WallMaterial ) ), context ) );

		return group;
	}

	/// <summary>
	/// The slab on top of a storey, in up to four panels around the stair opening.
	/// </summary>
	static PartPlan BuildSlab( HouseSettings s, GenContext context, string id, Material material, Rect3? hole )
	{
		var group = context.Group( id, "Ceiling", "Slab", FloorCategory );
		var z0 = s.StoreyHeight;
		var z1 = z0 + s.SlabThickness;

		void Panel( string name, string label, float x0, float x1, float y0, float y1 )
		{
			if ( x1 - x0 < 0.01f || y1 - y0 < 0.01f ) return;

			group.Add( context.Solid( $"{id}/{name}", label, "SlabPanel", FloorCategory, material,
				kit => kit.Box( new Vector3( x0, y0, z0 ), new Vector3( x1, y1, z1 ) ) ) );
		}

		if ( hole is not Rect3 h )
		{
			Panel( "panel", "Slab", 0.0f, s.Width, 0.0f, s.Depth );
			return group;
		}

		Panel( "front", "Front Slab", 0.0f, s.Width, 0.0f, h.Y0 );
		Panel( "back", "Back Slab", 0.0f, s.Width, h.Y1, s.Depth );
		Panel( "left", "Left Slab", 0.0f, h.X0, h.Y0, h.Y1 );
		Panel( "right", "Right Slab", h.X1, s.Width, h.Y0, h.Y1 );

		return group;
	}
}