editor/AdvancedMappingTools/Architecture/FacadeWallStudioEditor.cs

Editor tool and part generator for facade walls. Exposes settings for wall style (plain, panels, brick, stone, slats, classical), cladding, trims, piers, bands and slats, then builds a PartPlan of boxes for the core, cladding, mouldings and classical panels. The editor class wires it into the line tool for preview and placement.

namespace AdvancedMappingTools;
public enum StudioWallStyle
{
	Plain,
	Panels,
	Brick,
	Stone,
	Slats,
	Classical
}

public enum StudioSlatDirection
{
	Vertical,
	Horizontal
}

public sealed partial class FacadeWallStudioSettings : ArchitectureStudioSettings
{
	[Property, Group( "Wall" ), WideMode]
	public StudioWallStyle Style { get; set; } = StudioWallStyle.Panels;

	[Property, Group( "Wall" ), Range( 16, 1024, slider: false ), WideMode]
	public float Height { get; set; } = 128;

	[Property, Group( "Wall" ), Range( 2, 64, slider: false ), WideMode]
	public float Thickness { get; set; } = 8;

	[Property, Group( "Cladding" ), Title( "Cladding Depth" ), Range( .5f, 16, slider: false ), WideMode]
	public float CladdingDepth { get; set; } = 2;

	[Property, Group( "Cladding" ), Title( "Module Width" ), Range( 4, 128, slider: false ), WideMode]
	public float ModuleWidth { get; set; } = 32;

	[Property, Group( "Cladding" ), Title( "Row Height" ), Range( 4, 128, slider: false ), WideMode]
	public float RowHeight { get; set; } = 16;

	[Property, Group( "Cladding" ), Title( "Joint / Gap" ), Range( .25f, 8, slider: false ), WideMode]
	public float Joint { get; set; } = 1;

	[Property, Group( "Cladding" ), Title( "Both Sides" ), WideMode]
	public bool BothSides { get; set; }

	[Property, Group( "Classical" ), Title( "Panel Inset" ), Range( 2, 24, slider: false ), WideMode]
	public float PanelInset { get; set; } = 8;

	[Property, Group( "Classical" ), Title( "Moulding Width" ), Range( .5f, 8, slider: false ), WideMode]
	public float MouldingWidth { get; set; } = 2;

	[Property, Group( "Trims" ), Title( "Base Height" ), Range( 0, 64, slider: false ), WideMode]
	public float BaseHeight { get; set; } = 8;

	[Property, Group( "Trims" ), Title( "Cap Height" ), Range( 0, 32, slider: false ), WideMode]
	public float CapHeight { get; set; } = 4;

	[Property, Group( "Trims" ), Title( "Cap Overhang" ), Range( 0, 16, slider: false ), WideMode]
	public float CapOverhang { get; set; } = 2;

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

	[Property, Group( "Piers" ), Title( "Pier Spacing" ), Range( 16, 256, slider: false ), WideMode]
	public float PierSpacing { get; set; } = 96;

	[Property, Group( "Piers" ), Title( "Pier Width" ), Range( 2, 32, slider: false ), WideMode]
	public float PierWidth { get; set; } = 6;

	[Property, Group( "Facade Band" ), Title( "Horizontal Band" ), WideMode]
	public bool Band { get; set; }

	[Property, Group( "Facade Band" ), Title( "Band Position (%)" ), Range( 0, 100, slider: false ), WideMode]
	public float BandPosition { get; set; } = 60;

	[Property, Group( "Facade Band" ), Title( "Band Height" ), Range( 1, 32, slider: false ), WideMode]
	public float BandHeight { get; set; } = 6;

	[Property, Group( "Facade Band" ), Title( "Band Projection" ), Range( .5f, 16, slider: false ), WideMode]
	public float BandProjection { get; set; } = 2;

	[Property, Group( "Slats" ), Title( "Slat Direction" ), WideMode]
	public StudioSlatDirection SlatDirection { get; set; } = StudioSlatDirection.Vertical;

	/// <summary>Every second slat stands out this many times the cladding depth; 1 keeps all slats flush.</summary>
	[Property, Group( "Slats" ), Title( "Slat Relief" ), Range( 1, 3, slider: false ), WideMode]
	public float SlatRelief { get; set; } = 1.6f;

	internal float Relief => Style == StudioWallStyle.Slats ? Math.Clamp( float.IsFinite( SlatRelief ) ? SlatRelief : 1, 1, 3 ) : Style == StudioWallStyle.Stone ? 1.4f : 1;

	// Heights shared by the generator, the classical bays and the extras, so they never disagree.
	internal float WallHeight => Math.Clamp( Height, 16, 1024 );
	internal float Foot => MathF.Min( Math.Clamp( BaseHeight, 0, 64 ), WallHeight * .25f );
	internal float ClassicalCornice => Style == StudioWallStyle.Classical ? MathF.Min( 8.0f, (WallHeight - Foot) * .12f ) : 0;

	internal (float Low, float High) BandRange
	{
		get
		{
			var h = WallHeight;
			var height = MathF.Min( Math.Clamp( BandHeight, 1, 32 ), h - Foot );
			var low = Foot + (h - Foot - height) * Math.Clamp( BandPosition, 0, 100 ) / 100;
			return (low, low + height);
		}
	}

	/// <summary>The repeated cornices that are built: none on the band, none in the classical cornice.</summary>
	internal List<(float Low, float High)> CorniceRows()
	{
		var rows = new List<(float Low, float High)>();
		if ( !RepeatedBands )
			return rows;
		var spacing = Math.Clamp( CorniceSpacing, 16, 256 );
		var height = MathF.Min( Math.Clamp( CorniceHeight, 1, 16 ), spacing * .5f );
		var top = WallHeight - ClassicalCornice;
		var band = BandRange;
		for ( var z = spacing; z < WallHeight; z += spacing )
		{
			if ( z > top + .001f || z - height < Foot - .001f )
				continue;
			if ( Band && z > band.Low && z - height < band.High )
				continue;
			rows.Add( (z - height, z) );
		}

		return rows;
	}

	public override IReadOnlySet<string> UnusedOptions
	{
		get
		{
			var set = new HashSet<string>( base.UnusedOptions );
			StudioExtras.HideFacade( set, this );
			if ( !Band )
				set.UnionWith( [nameof( BandPosition ), nameof( BandHeight ), nameof( BandProjection )] );
			if ( !Piers )
			{
				set.Add( nameof( PierSpacing ) );
				set.Add( nameof( PierWidth ) );
			}

			if ( Style is StudioWallStyle.Plain or StudioWallStyle.Classical )
				foreach ( var n in new[]
				{
					nameof( ModuleWidth ),
					nameof( RowHeight ),
					nameof( Joint )
				}

 )
					set.Add( n );
			if ( Style == StudioWallStyle.Slats )
				set.Add( SlatDirection == StudioSlatDirection.Horizontal ? nameof( ModuleWidth ) : nameof( RowHeight ) );
			if ( Style != StudioWallStyle.Slats )
				set.UnionWith( [nameof( SlatDirection ), nameof( SlatRelief )] );
			if ( Style != StudioWallStyle.Classical )
			{
				set.Add( nameof( PanelInset ) );
				set.Add( nameof( MouldingWidth ) );
			}

			return set;
		}
	}
}

public sealed class FacadeWallStudioGenerator : IPartGenerator
{
	public string Id => "facade_wall_studio";
	public string Title => "Facade Wall Studio";
	public Type SettingsType => typeof(FacadeWallStudioSettings);

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (FacadeWallStudioSettings)settings;
		var p = new ArchitectureStudioPlan( context, s, Title );
		var w = MathF.Max( s.Width, 16 );
		var h = Math.Clamp( s.Height, 16, 1024 );
		var thick = Math.Clamp( s.Thickness, 2, 64 );
		var skin = Math.Clamp( s.CladdingDepth, .5f, 16 );
		var foot = s.Foot;
		var cap = MathF.Min( Math.Clamp( s.CapHeight, 0, 32 ), h * .25f );
		var over = Math.Clamp( s.CapOverhang, 0, 16 );
		p.Box( "core", "Wall Core", p.Structure, new( 0, 0, 0 ), new( w, thick, h ) );
		if ( foot > 0 )
			p.Box( "base", "Base Moulding", p.Detail, new( 0, -skin, 0 ), new( w, 0, foot ) );
		if ( cap > 0 )
			p.Box( "cap", "Wall Cap", p.Detail, new( -over, -skin - over, h ), new( w + over, thick + skin + over, h + cap ) );
		var bays = s.Piers ? Math.Clamp( (int)MathF.Ceiling( w / Math.Clamp( s.PierSpacing, 16, 256 ) ), 1, 64 ) : 1;
		var pier = s.Piers ? MathF.Min( Math.Clamp( s.PierWidth, 2, 32 ), w / (bays + 1) * .5f ) : 0;
		var bandLow = s.BandRange.Low;
		var bandHeight = s.BandRange.High - s.BandRange.Low;

		// Cladding and piers stop at the band, so the band only touches them.
		void Clad( MeshKit kit, Vector3 a, Vector3 b )
		{
			if ( !s.Band || b.z <= bandLow || a.z >= bandLow + bandHeight )
			{
				kit.Box( a, b );
				return;
			}

			if ( bandLow - a.z > .01f )
				kit.Box( a, b.WithZ( bandLow ) );
			if ( b.z - (bandLow + bandHeight) > .01f )
				kit.Box( a.WithZ( bandLow + bandHeight ), b );
		}
		void Side( bool back )
		{
			var prefix = back ? "back" : "front";
			var y0 = back ? thick : -skin;
			var y1 = back ? thick + skin : 0;
			if ( back && foot > 0 )
				p.Box( "back_base", "Back Base Moulding", p.Detail, new( 0, y0, 0 ), new( w, y1, foot ) );
			var sections = new List<(float start, float end)>();
			if ( s.Piers )
			{
				var slot = (w - pier) / bays;
				for ( var i = 0; i <= bays; i++ )
				{
					var x = i * slot;
					p.Add( prefix + "_pier" + i, "Facade Pier", p.Detail, kit => Clad( kit, new( x, y0, foot ), new( x + pier, y1, h ) ) );
				}
				for ( var i = 0; i < bays; i++ )
					sections.Add( (i * slot + pier, (i + 1) * slot) );
			}
			else
				sections.Add( (0, w) );
			for ( var bay = 0; bay < sections.Count; bay++ )
			{
				var(start, end) = sections[bay];
				var bh = h - foot;
				p.Add( prefix + "_cladding" + bay, "Facade Finish", p.Finish, kit =>
				{
					if ( s.Style is StudioWallStyle.Plain or StudioWallStyle.Classical )
					{
						Clad( kit, new( start, y0, foot ), new( end, y1, h ) );
						return;
					}

					if ( s.Style == StudioWallStyle.Slats && s.SlatDirection == StudioSlatDirection.Horizontal )
					{
						// Horizontal boards across the bay, a shadow gap between, every second one standing out.
						var rows = Math.Clamp( (int)MathF.Ceiling( bh / MathF.Max( Math.Clamp( s.RowHeight, 4, 128 ) / 3.0f, 4 ) ), 1, 128 );
						var board = bh / rows;
						var shadow = MathF.Min( MathF.Max( Math.Clamp( s.Joint, .25f, 8 ), board * .18f ), board * .4f );
						for ( var row = 0; row < rows; row++ )
						{
							var relief = row % 2 == 1 ? s.Relief : 1;
							var z0 = foot + row * board + (row == 0 ? 0 : shadow * .5f);
							var z1 = foot + (row + 1) * board - (row == rows - 1 ? 0 : shadow * .5f);
							Clad( kit, new( start, back ? thick : -skin * relief, z0 ), new( end, back ? thick + skin * relief : 0, z1 ) );
						}

						return;
					}

					var pitch = Math.Clamp( s.ModuleWidth, 4, 128 ) / (s.Style == StudioWallStyle.Slats ? 3.0f : 1.0f);
					var nx = Math.Clamp( (int)MathF.Ceiling( (end - start) / MathF.Max( pitch, 4 ) ), 1, Math.Max( 1, 128 / bays ) );
					var ny = s.Style == StudioWallStyle.Slats ? 1 : Math.Clamp( (int)MathF.Ceiling( bh / Math.Clamp( s.RowHeight, 4, 128 ) ), 1, 64 );
					var sx = (end - start) / nx;
					var sz = bh / ny;
					var gap = MathF.Min( Math.Clamp( s.Joint, .25f, 8 ), MathF.Min( sx, sz ) * .4f );
					// Slats: a shadow gap between two boards, wide enough to read from afar.
					if ( s.Style == StudioWallStyle.Slats )
						gap = MathF.Min( MathF.Max( gap, sx * .18f ), sx * .4f );
					for ( var row = 0; row < ny; row++ )
					{
						var shift = s.Style is StudioWallStyle.Brick or StudioWallStyle.Stone && row % 2 == 1 ? sx * .5f : 0;
						for ( var i = -1; i <= nx; i++ )
						{
							var x0 = MathF.Max( start, start + i * sx + shift + gap * .5f );
							var x1 = MathF.Min( end, start + (i + 1) * sx + shift - gap * .5f );
							if ( x1 - x0 < .1f )
								continue;
							var z0 = foot + row * sz + (s.Style == StudioWallStyle.Slats ? 0 : gap * .5f);
							var z1 = foot + (row + 1) * sz - (s.Style == StudioWallStyle.Slats ? 0 : gap * .5f);
							var relief = s.Style == StudioWallStyle.Stone ? 1 + ((row + i + 10) % 3) * .2f : s.Style == StudioWallStyle.Slats && (i + 10) % 2 == 1 ? s.Relief : 1;
							Clad( kit, new( x0, back ? thick : -skin * relief, z0 ), new( x1, back ? thick + skin * relief : 0, z1 ) );
						}
					}
				} );
				if ( s.Style == StudioWallStyle.Classical )
					Classical( p, s, prefix, bay, start, end, foot, h, bh, back, back ? thick + skin : -skin, bandLow, bandHeight );
			}
		}

		if ( s.Band )
		{
			var height = bandHeight;
			var low = bandLow;
			// Extend into the cladding to meet the structural core, including across joints.
			var face = skin * s.Relief;
			var projection = Math.Clamp( s.BandProjection, .5f, 16 );
			p.Box( "band_front", "Front Facade Band", p.Detail, new( 0, -face - projection, low ), new( w, 0, low + height ) );
			if ( s.BothSides )
				p.Box( "band_back", "Back Facade Band", p.Detail, new( 0, thick, low ), new( w, thick + face + projection, low + height ) );
		}

		Side( false );
		if ( s.BothSides )
			Side( true );
		StudioExtras.Facade( p, s, w, h, thick, skin );
		return p.Root;
	}

	/// <summary>
	/// A classical bay: a stepped cornice under the cap, a dado rail at a third of the height, and
	/// a raised panel in a moulded frame below and above it. <paramref name="face"/> is the outer
	/// face of the finish; everything stands out from it, away from the wall.
	/// </summary>
	static void Classical( ArchitectureStudioPlan p, FacadeWallStudioSettings s, string prefix, int bay, float start, float end, float foot, float h, float bh, bool back, float face, float bandLow, float bandHeight )
	{
		var outward = back ? 1.0f : -1.0f;
		Vector3 At( float x, float depth, float z ) => new( x, face + outward * depth, z );
		void Slab( MeshKit kit, float x0, float x1, float d0, float d1, float z0, float z1 )
		{
			var a = At( x0, d0, z0 );
			var c = At( x1, d1, z1 );
			kit.Box( Vector3.Min( a, c ), Vector3.Max( a, c ) );
		}

		// Cornice: two steps, the upper one standing further out.
		var cornice = s.ClassicalCornice;
		// With a facade band, the band is the dado rail; a band up at the cap replaces the cornice.
		if ( s.Band && bandLow + bandHeight > h - cornice )
			cornice = 0;
		if ( cornice > 0 )
			p.Add( prefix + "_cornice" + bay, "Classical Cornice", p.Detail, kit =>
			{
				Slab( kit, start, end, 0, 1.5f, h - cornice, h - cornice * .45f );
				Slab( kit, start, end, 0, 3.0f, h - cornice * .45f, h );
			} );

		if ( end - start <= 12 ) return;

		var inset = MathF.Min( Math.Clamp( s.PanelInset, 2, 24 ), MathF.Min( end - start, bh ) * .2f );
		var a = start + inset;
		var c = end - inset;
		var m = MathF.Min( Math.Clamp( s.MouldingWidth, .5f, 8 ), (c - a) * .15f );

		// The band and the repeated cornices divide the bay; without them a dado rail sits at a third.
		var dividers = s.CorniceRows();
		if ( s.Band )
			dividers.Add( (bandLow, bandLow + bandHeight) );
		var dado = foot + bh * .33f;
		var rail = MathF.Min( 3.0f, bh * .04f );
		if ( dividers.Count == 0 )
		{
			dividers.Add( (dado, dado + rail) );
			p.Box( prefix + "_dado" + bay, "Dado Rail", p.Detail, Vector3.Min( At( start, 0, dado ), At( end, 2.0f, dado + rail ) ), Vector3.Max( At( start, 0, dado ), At( end, 2.0f, dado + rail ) ) );

		}

		dividers.Sort();
		var fields = new List<(float Low, float High)>();
		var below = foot + inset * .5f;
		foreach ( var (low, high) in dividers )
		{
			if ( low - inset * .5f - below > m * 3 )
				fields.Add( (below, low - inset * .5f) );
			below = high + inset;
		}

		if ( h - cornice - inset - below > m * 3 )
			fields.Add( (below, h - cornice - inset) );

		p.Add( prefix + "_moulding" + bay, "Classical Panel Moulding", p.Detail, kit =>
		{
			foreach ( var (low, high) in fields )
			{
				Slab( kit, a, c, 0, 1.5f, low, low + m );
				Slab( kit, a, c, 0, 1.5f, high - m, high );
				Slab( kit, a, a + m, 0, 1.5f, low + m, high - m );
				Slab( kit, c - m, c, 0, 1.5f, low + m, high - m );
			}
		} );
		p.Add( prefix + "_panels" + bay, "Raised Panels", p.Finish, kit =>
		{
			foreach ( var (low, high) in fields )
				Slab( kit, a + m, c - m, 0, 0.75f, low + m, high - m );
		} );
	}
}

[ToolCategory( "Walls & Floors" ), Title( "Facade Wall Studio" ), Icon( "view_week" )]
public sealed class FacadeWallStudioEditor( PrimitiveTool tool ) : LineEditor( tool )
{
	readonly FacadeWallStudioGenerator generator = new();
	public FacadeWallStudioSettings Settings { get; } = new();
	public override object ExtraSettings => Settings;
	protected override bool PlacesObjects => true;

	public override bool ChangesOtherSettings( string property ) => property is nameof( FacadeWallStudioSettings.Fins ) or nameof( FacadeWallStudioSettings.RepeatedBands ) or nameof( FacadeWallStudioSettings.Style ) or nameof( FacadeWallStudioSettings.Piers ) or nameof( FacadeWallStudioSettings.Band ) or nameof( FacadeWallStudioSettings.SlatDirection );
	void Prepare()
	{
		Settings.Width = Length;
		Settings.StructureMaterial = PartTool.Pick( Settings.StructureMaterial, SurfaceMaterial );
		Settings.DetailMaterial = PartTool.Pick( Settings.DetailMaterial, TrimMaterial );
		Settings.FinishMaterial = PartTool.Pick( Settings.FinishMaterial, SurfaceMaterial );
	}

	protected override void BuildMesh( MeshKit kit )
	{
	}

	protected override PolygonMesh BuildPreview()
	{
		Prepare();
		return PartService.Plan( generator, Settings ).Flatten( LineFrame );
	}

	protected override void PlaceObjects()
	{
		Prepare();
		PartTool.Place( generator, GeneratorRegistry.Load( generator, GeneratorRegistry.Save( Settings ) ), LineFrame );
	}
}