editor/AdvancedMappingTools/Architecture/ShellEditors.cs

Editor tooling for architecture primitives. Defines a Shell helper that builds four walls with door/window openings and trim frames, plus Room, Building and Bunker editors that generate meshes from a rectangle.

namespace AdvancedMappingTools;

public enum RoomSide
{
	None,
	Front,
	Right,
	Back,
	Left
}

/// <summary>
/// Walls around a rectangle, drawn counter clockwise with their insides to the left.
/// </summary>
public static class Shell
{
	public record struct Openings( IReadOnlyList<(float Start, float End)> Spans, float Sill, float Head, ArchStyle Arch );

	public static List<(float Start, float End)> Even( float length, int count, float width )
	{
		var result = new List<(float, float)>( count );
		if ( count <= 0 ) return result;

		var slot = length / count;
		width = Math.Clamp( width, 2.0f, MathF.Max( 2.0f, slot - 2.0f ) );

		for ( var i = 0; i < count; i++ )
		{
			var center = slot * (i + 0.5f);
			result.Add( (center - width * 0.5f, center + width * 0.5f) );
		}

		return result;
	}

	/// <summary>
	/// Add four walls. The walls of side 1 and 3 fit between those of side 0 and 2.
	/// <paramref name="openings"/> gets the side and the free length of its wall.
	/// </summary>
	public static void Walls( MeshKit kit, Transform frame, float length, float depth, float thickness, float z, float height,
		Func<int, float, Openings> openings, float frameWidth, Material surface, Material trim )
	{
		var half = thickness * 0.5f;

		(Vector3 Corner, float Yaw, float Length)[] sides =
		[
			(new Vector3( 0.0f, half, z ), 0.0f, length),
			(new Vector3( length - half, thickness, z ), 90.0f, depth - thickness * 2.0f),
			(new Vector3( length, depth - half, z ), 180.0f, length),
			(new Vector3( half, depth - thickness, z ), 270.0f, depth - thickness * 2.0f)
		];

		for ( var i = 0; i < sides.Length; i++ )
		{
			var (corner, yaw, span) = sides[i];
			if ( span <= 1.0f ) continue;

			var open = openings( i, span );

			kit.Frame = frame.ToWorld( new Transform( corner, Rotation.FromYaw( yaw ) ) );
			kit.Material = surface;

			if ( open.Spans.Count == 0 )
			{
				kit.WallWithOpenings( span, thickness, height, open.Spans, 0.0f, height );
				continue;
			}

			var width = open.Spans[0].End - open.Spans[0].Start;
			var rise = MathF.Min( MeshKit.ArchRise( open.Arch, width ), open.Head - open.Sill - 1.0f );

			var depthOut = half + 1.0f;
			var flat = open.Arch == ArchStyle.Flat || rise <= 0.0f;
			var head = MathF.Min( open.Head, height - 1.0f );

			if ( flat ) kit.WallWithOpenings( span, thickness, height, open.Spans, open.Sill, head );
			else kit.WallWithArches( span, thickness, height, open.Spans, open.Sill, head - rise, rise, open.Arch, 12 );

			kit.Material = trim;

			foreach ( var (x0, x1) in open.Spans )
			{
				if ( flat ) kit.OpeningFrame( x0, x1, open.Sill, head, frameWidth, depthOut, open.Sill > 0.0f );
				else kit.ArchFrame( x0, x1, open.Sill, head - rise, rise, open.Arch, 12, frameWidth, depthOut, open.Sill > 0.0f );
			}
		}

		kit.Frame = frame;
	}
}

/// <summary>
/// A closed room with floor, ceiling, walls, a door and windows, in three clicks.
/// </summary>
[ToolCategory( "Buildings" )]
[Title( "Room" ), Icon( "meeting_room" )]
public sealed class RoomEditor( PrimitiveTool tool ) : RectEditor( tool )
{
	[Property, Group( "Room" ), Range( 8, 2048, slider: false ), WideMode]
	public float Height { get; set; } = 128.0f;

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

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

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

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

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

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

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

	[Property, Group( "Openings" ), Title( "Windows Per Side" ), Range( 0, 16, slider: false ), WideMode]
	public int WindowsPerSide { get; set; } = 1;

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

	[Property, HideIf( nameof( WindowsPerSide ), 0 ), Group( "Openings" ), Title( "Window Height" ), Range( 8, 512, slider: false ), WideMode]
	public float WindowHeight { get; set; } = 48.0f;

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

	[Property, HideIf( nameof( WindowsPerSide ), 0 ), Group( "Openings" ), Title( "Window Arch" ), WideMode]
	public ArchStyle WindowArch { get; set; } = ArchStyle.Flat;

	protected override void BuildMesh( MeshKit kit )
	{
		var frame = RectFrame;
		var length = RectLength;
		var depth = RectDepth;

		Shell.Walls( kit, frame, length, depth, WallThickness, 0.0f, Height, ( side, span ) =>
		{
			if ( (int)DoorSide - 1 == side )
				return new Shell.Openings( Shell.Even( span, 1, DoorWidth ), 0.0f, DoorHeight, ArchStyle.Flat );

			return new Shell.Openings( Shell.Even( span, WindowsPerSide, WindowWidth ), SillHeight, SillHeight + WindowHeight, WindowArch );
		}, 3.0f, SurfaceMaterial, TrimMaterial );

		kit.Material = TrimMaterial;
		if ( Floor ) kit.Box( new Vector3( 0.0f, 0.0f, -SlabThickness ), new Vector3( length, depth, 0.0f ) );
		if ( Ceiling ) kit.Box( new Vector3( 0.0f, 0.0f, Height ), new Vector3( length, depth, Height + SlabThickness ) );
	}
}

/// <summary>
/// A whole building shell: foundation, storeys with windows, an entrance and a roof.
/// </summary>
[ToolCategory( "Buildings" )]
[Title( "Building" ), Icon( "apartment" )]
public sealed class BuildingEditor( PrimitiveTool tool ) : RectEditor( tool )
{
	[Property, Group( "Building" ), Range( 1, 64, slider: false ), WideMode]
	public int Storeys { get; set; } = 2;

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

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

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

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

	[Property, Group( "Openings" ), Title( "Door Width" ), Range( 8, 512, slider: false ), WideMode]
	public float DoorWidth { get; set; } = 64.0f;

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

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

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

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

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

	[Property, Group( "Openings" ), Title( "Window Arch" ), WideMode]
	public ArchStyle WindowArch { get; set; } = ArchStyle.Flat;

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

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

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

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

	protected override void BuildMesh( MeshKit kit )
	{
		var frame = RectFrame;
		var length = RectLength;
		var depth = RectDepth;
		var z = 0.0f;

		kit.Frame = frame;
		kit.Material = TrimMaterial;

		if ( Foundation > 0.0f )
		{
			kit.Box( new Vector3( -2.0f, -2.0f, 0.0f ), new Vector3( length + 2.0f, depth + 2.0f, Foundation ) );
			z = Foundation;
		}

		for ( var storey = 0; storey < Storeys; storey++ )
		{
			var current = storey;

			Shell.Walls( kit, frame, length, depth, WallThickness, z, StoreyHeight, ( side, span ) =>
			{
				if ( current == 0 && side == 0 )
					return new Shell.Openings( Shell.Even( span, 1, DoorWidth ), 0.0f, DoorHeight, ArchStyle.Flat );

				var count = (int)MathF.Floor( span / MathF.Max( WindowSpacing, 32.0f ) );
				return new Shell.Openings( Shell.Even( span, count, WindowWidth ), SillHeight, SillHeight + WindowHeight, WindowArch );
			}, 2.0f, SurfaceMaterial, TrimMaterial );

			z += StoreyHeight;

			kit.Frame = frame;
			kit.Material = TrimMaterial;
			kit.Box( new Vector3( 0.0f, 0.0f, z ), new Vector3( length, depth, z + SlabThickness ) );
			z += SlabThickness;
		}

		var top = frame.ToWorld( new Transform( Vector3.Up * z ) );
		kit.Frame = top;
		RoofEditor.BuildRoof( kit, top, -Overhang, length + Overhang, -Overhang, depth + Overhang,
			RoofShape, RidgeHeight, 4.0f, RoofMaterial ?? SurfaceMaterial, TrimMaterial );
	}
}

/// <summary>
/// A low concrete bunker with firing slits and a thick roof.
/// </summary>
[ToolCategory( "Buildings" )]
[Title( "Bunker" ), Icon( "shield" )]
public sealed class BunkerEditor( PrimitiveTool tool ) : RectEditor( tool )
{
	[Property, Group( "Bunker" ), Range( 32, 512, slider: false ), WideMode]
	public float Height { get; set; } = 96.0f;

	[Property, Group( "Bunker" ), Title( "Wall Thickness" ), Range( 4, 128, slider: false ), WideMode]
	public float WallThickness { get; set; } = 24.0f;

	[Property, Group( "Bunker" ), Title( "Roof Thickness" ), Range( 4, 128, slider: false ), WideMode]
	public float RoofThickness { get; set; } = 24.0f;

	[Property, Group( "Bunker" ), Title( "Slits Per Side" ), Range( 0, 16, slider: false ), WideMode]
	public int SlitsPerSide { get; set; } = 2;

	[Property, HideIf( nameof( SlitsPerSide ), 0 ), Group( "Bunker" ), Title( "Slit Width" ), Range( 8, 256, slider: false ), WideMode]
	public float SlitWidth { get; set; } = 64.0f;

	[Property, HideIf( nameof( SlitsPerSide ), 0 ), Group( "Bunker" ), Title( "Slit Height" ), Range( 4, 128, slider: false ), WideMode]
	public float SlitHeight { get; set; } = 12.0f;

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

	protected override void BuildMesh( MeshKit kit )
	{
		var frame = RectFrame;
		var length = RectLength;
		var depth = RectDepth;
		var sill = Height * 0.55f;

		Shell.Walls( kit, frame, length, depth, WallThickness, 0.0f, Height, ( side, span ) => side == 2
			? new Shell.Openings( Shell.Even( span, 1, DoorWidth ), 0.0f, MathF.Min( 80.0f, Height - 4.0f ), ArchStyle.Flat )
			: new Shell.Openings( Shell.Even( span, SlitsPerSide, SlitWidth ), sill, sill + SlitHeight, ArchStyle.Flat ),
			0.0f, SurfaceMaterial, TrimMaterial );

		kit.Material = TrimMaterial;
		kit.Box( new Vector3( -8.0f, -8.0f, Height ), new Vector3( length + 8.0f, depth + 8.0f, Height + RoofThickness ) );
		kit.Box( new Vector3( 0.0f, 0.0f, -8.0f ), new Vector3( length, depth, 0.0f ) );
	}
}