editor/AdvancedMappingTools/Architecture/BalconyStudioEditor.cs

Editor-side part generator and settings for a Balcony Studio tool. Builds balcony slab, railing frames and infill (glass, bars, slats, solid, classical turned balusters), privacy screens and wall brackets from the settings, and hides unused options in the inspector.

namespace AdvancedMappingTools;
public enum StudioBalconyStyle
{
	Glass,
	Bars,
	Horizontal,
	Slats,
	Solid,
	Classical
}

public enum StudioBalusterShape
{
	Vase,
	Straight,
	Square
}

public sealed partial class BalconyStudioSettings : ArchitectureStudioSettings, ISurfaceAwareSettings
{
	[Property, Group( "Balcony" ), WideMode]
	public StudioBalconyStyle Style { get; set; } = StudioBalconyStyle.Glass;

	[Property, Group( "Balcony" ), Range( 1, 64, slider: false ), WideMode]
	public float Thickness { get; set; } = 6;

	[Property, Group( "Railing" ), Title( "Railing Height" ), Range( 12, 128, slider: false ), WideMode]
	public float RailingHeight { get; set; } = 40;

	[Property, Group( "Railing" ), Title( "Bay Width" ), Range( 16, 128, slider: false ), WideMode]
	public float BayWidth { get; set; } = 48;

	[Property, Group( "Railing" ), Title( "Frame Width" ), Range( 1, 8, slider: false ), WideMode]
	public float FrameWidth { get; set; } = 2;

	[Property, Group( "Railing" ), Title( "Infill Spacing" ), Range( 4, 32, slider: false ), WideMode]
	public float InfillSpacing { get; set; } = 8;

	[Property, Group( "Railing" ), Title( "Infill Thickness" ), Range( .25f, 8, slider: false ), WideMode]
	public float InfillThickness { get; set; } = .5f;

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

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

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

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

	[Property, Group( "Supports" ), Title( "Bracket Drop" ), Range( 4, 128, slider: false ), WideMode]
	public float BracketDrop { get; set; } = 32;

	[Property, Group( "Materials" ), Title( "Glass" ), ResourceType( "vmat" ), WideMode]
	public string GlassMaterial { get; set; } = "materials/glass/glass_a.vmat";

	[Property, Group( "Privacy" ), Title( "Left Privacy Screen" ), WideMode]
	public bool PrivacyLeft { get; set; }

	[Property, Group( "Privacy" ), Title( "Right Privacy Screen" ), WideMode]
	public bool PrivacyRight { get; set; }

	[Property, Group( "Privacy" ), Title( "Screen Height" ), Range( 12, 192, slider: false ), WideMode]
	public float PrivacyHeight { get; set; } = 72;

	[Property, Group( "Privacy" ), Title( "Screen Thickness" ), Range( .25f, 8, slider: false ), WideMode]
	public float PrivacyThickness { get; set; } = 1;

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

	/// <summary>Shape of the balusters of a Classical railing.</summary>
	[Property, Group( "Railing" ), Title( "Baluster Shape" ), WideMode]
	public StudioBalusterShape BalusterShape { get; set; } = StudioBalusterShape.Vase;

	bool AnyClassical => SeparateRailingStyles ? FrontStyle == StudioBalconyStyle.Classical || LeftStyle == StudioBalconyStyle.Classical || RightStyle == StudioBalconyStyle.Classical : Style == StudioBalconyStyle.Classical;

	public override IReadOnlySet<string> UnusedOptions
	{
		get
		{
			var set = new HashSet<string>( base.UnusedOptions );
			StudioExtras.HideBalcony( set, this );
			HideGroundSettings( set );
			set.UnionWith( AnchorIntegration.Hidden( this ) );
			if ( !SeparateRailingStyles )
				set.UnionWith( [nameof( FrontStyle ), nameof( LeftStyle ), nameof( RightStyle )] );
			if ( !CornerBalcony )
				set.UnionWith( [nameof( CornerSide ), nameof( ReturnLength )] );
			if ( !SupportColumns )
				set.UnionWith( [nameof( ColumnHeight ), nameof( ColumnWidth )] );
			if ( !FloorCovering )
				set.UnionWith( [nameof( FloorFinish ), nameof( FloorModule ), nameof( FloorCoverThickness ), nameof( FloorMaterial )] );
			if ( PrivacyLeft )
				set.Add( nameof( Left ) );
			if ( PrivacyRight )
				set.Add( nameof( Right ) );
			if ( !PrivacyLeft && !PrivacyRight )
				set.UnionWith( [nameof( PrivacyHeight ), nameof( PrivacyThickness ), nameof( PrivacyMaterial )] );
			if ( !Brackets )
				set.Add( nameof( BracketDrop ) );
			if ( !AnyClassical )
				set.Add( nameof( BalusterShape ) );
			if ( !SeparateRailingStyles && Style is (StudioBalconyStyle.Glass or StudioBalconyStyle.Solid) )
				set.Add( nameof( InfillSpacing ) );
			if ( !SeparateRailingStyles && Style != StudioBalconyStyle.Glass )
				set.Add( nameof( GlassMaterial ) );
			else if ( !SeparateRailingStyles )
				set.Add( nameof( FinishMaterial ) );
			if ( !Front && (!Left || PrivacyLeft) && (!Right || PrivacyRight) )
				foreach ( var n in new[]
				{
					nameof( Style ),
					nameof( RailingHeight ),
					nameof( BayWidth ),
					nameof( InfillSpacing ),
					nameof( InfillThickness ),
					nameof( FinishMaterial ),
					nameof( GlassMaterial )
				}

 )
					set.Add( n );
			if ( !Front && (!Left || PrivacyLeft) && (!Right || PrivacyRight) )
				set.Add( nameof( FrameWidth ) );
			if ( !Front && !Left && !Right && !Brackets )
				set.Add( nameof( DetailMaterial ) );
			return set;
		}
	}
}

public sealed partial class BalconyStudioGenerator : IPartGenerator
{
	public string Id => "balcony_studio";
	public string Title => "Balcony Studio";
	public Type SettingsType => typeof(BalconyStudioSettings);

	PartPlan GenerateSingle( GeneratorSettings settings, GenContext context )
	{
		var s = (BalconyStudioSettings)settings;
		var p = new ArchitectureStudioPlan( context, s, Title );
		var w = MathF.Max( s.Width, 16 );
		var d = MathF.Max( s.Depth, 16 );
		var t = Math.Clamp( s.Thickness, 1, 64 );
		var rail = Math.Clamp( s.RailingHeight, 12, 128 );
		var fw = MathF.Min( Math.Clamp( s.FrameWidth, 1, 8 ), MathF.Min( MathF.Min( w, d ) * .2f, rail * .25f ) );
		var panel = MathF.Min( Math.Clamp( s.InfillThickness, .25f, 8 ), fw );
		p.Box( "slab", "Balcony Slab", p.Structure, new( 0, 0, 0 ), new( w, d, t ) );
		void Edge( string id, Vector3 origin, float yaw, float length )
		{
			var style = s.SeparateRailingStyles ? (id == "front" ? s.FrontStyle : id == "left" ? s.LeftStyle : s.RightStyle) : s.Style;
			var frame = new Transform( origin + Vector3.Up * (s.Elevation + t), Rotation.FromYaw( yaw ) );

			// Classical: turned balusters on a plinth, under a rail, both as wide as the balusters.
			// Plinth and rail sit between the frame rails and posts and only touch them.
			void Balusters( MeshKit kit, float x0, float x1, float low, float high )
			{
				var h = high - low;
				var r = MathF.Min( MathF.Max( fw * .9f, 1.5f ), MathF.Min( h * .08f, 3.5f ) );
				var band = MathF.Min( MathF.Max( r * .9f, fw ), h * .12f );
				var half = r * 1.15f;
				var mid = fw * .5f;
				kit.Box( new( x0, mid - half, low ), new( x1, mid + half, low + band ) );
				kit.Box( new( x0, mid - half, high - band ), new( x1, mid + half, high ) );
				var span = x1 - x0;
				var count = Math.Clamp( (int)MathF.Floor( span / MathF.Max( Math.Clamp( s.InfillSpacing, 4, 32 ), r * 2.6f ) ), 1, 64 );
				var step = span / count;
				var bh = h - band * 2;
				if ( bh < r * 2 )
					return;
				for ( var k = 0; k < count; k++ )
				{
					var x = x0 + (k + .5f) * step;
					if ( s.BalusterShape == StudioBalusterShape.Square )
					{
						// A square post with a block at the foot and the head.
						var block = MathF.Min( bh * .1f, r * 2 );
						var stem = r * .6f;
						kit.Box( new( x - r * .9f, mid - r * .9f, low + band ), new( x + r * .9f, mid + r * .9f, low + band + block ) );
						kit.Box( new( x - stem, mid - stem, low + band + block ), new( x + stem, mid + stem, high - band - block ) );
						kit.Box( new( x - r * .9f, mid - r * .9f, high - band - block ), new( x + r * .9f, mid + r * .9f, high - band ) );
						continue;
					}

					if ( s.BalusterShape == StudioBalusterShape.Straight )
					{
						kit.Lathe( new( x, mid, low + band ), [new( 0, 0 ), new( r, 0 ), new( r, bh * .08f ), new( r * .6f, bh * .1f ), new( r * .6f, bh * .9f ), new( r, bh * .92f ), new( r, bh ), new( 0, bh )], 12 );
						continue;
					}

					kit.Lathe( new( x, mid, low + band ), [new( 0, 0 ), new( r, 0 ), new( r, bh * .07f ), new( r * .55f, bh * .12f ), new( r * .7f, bh * .2f ), new( r * .95f, bh * .38f ), new( r * .75f, bh * .55f ), new( r * .4f, bh * .7f ), new( r * .4f, bh * .8f ), new( r * .7f, bh * .86f ), new( r * .7f, bh * .93f ), new( r, bh * .93f ), new( r, bh ), new( 0, bh )], 12 );
				}
			}
			var bays = Math.Clamp( (int)MathF.Ceiling( length / Math.Clamp( s.BayWidth, 16, 128 ) ), 1, 64 );
			var slot = (length - fw) / bays;
			p.Add( id + "_frame", "Railing Frame", p.Detail, kit =>
			{
				kit.Frame = frame;
				kit.Box( new( 0, 0, 0 ), new( length, fw, fw ) );
				kit.Box( new( 0, 0, rail - fw ), new( length, fw, rail ) );
				for ( var i = 0; i <= bays; i++ )
					kit.Box( new( i * slot, 0, fw ), new( i * slot + fw, fw, rail - fw ) );
			} );
			p.Add( id + "_infill", "Railing Infill", style == StudioBalconyStyle.Glass ? context.Material( s.GlassMaterial ) : p.Finish, kit =>
			{
				kit.Frame = frame;
				for ( var bay = 0; bay < bays; bay++ )
				{
					var x0 = bay * slot + fw;
					var x1 = (bay + 1) * slot;
					var low = fw;
					var high = rail - fw;
					if ( x1 - x0 < .1f )
						continue;
					if ( style == StudioBalconyStyle.Classical )
					{
						Balusters( kit, x0, x1, low, high );
						continue;
					}
					if ( style is StudioBalconyStyle.Glass or StudioBalconyStyle.Solid )
					{
						kit.Box( new( x0, (fw - panel) * .5f, low ), new( x1, (fw + panel) * .5f, high ) );
						continue;
					}

					var horizontal = style == StudioBalconyStyle.Horizontal;
					var span = horizontal ? high - low : x1 - x0;
					var count = Math.Clamp( (int)MathF.Ceiling( span / Math.Clamp( s.InfillSpacing, 4, 32 ) ), 1, 64 );
					var step = span / count;
					for ( var k = 0; k < count; k++ )
					{
						var center = (k + .5f) * step;
						if ( horizontal )
						{
							kit.Box( new( x0, (fw - panel) * .5f, low + center - panel * .5f ), new( x1, (fw + panel) * .5f, low + center + panel * .5f ) );
							continue;
						}

						var bar = style == StudioBalconyStyle.Slats ? MathF.Min( step * .7f, fw * 2 ) : MathF.Min( panel, step * .5f );
						kit.Box( new( x0 + center - bar * .5f, (fw - panel) * .5f, low ), new( x0 + center + bar * .5f, (fw + panel) * .5f, high ) );
					}
				}
			} );
		}

		if ( s.Front )
		{
			var gap = s.CornerBalcony ? MathF.Min( d, w * .5f ) : 0;
			Edge( "front", new( s.CornerBalcony && s.CornerSide == BalconyCorner.Left ? gap : 0, d - fw, 0 ), 0, w - gap );
		}

		if ( s.Left && !s.PrivacyLeft )
			Edge( "left", new( fw, 0, 0 ), 90, d - (s.Front ? fw : 0) );
		if ( s.Right && !s.PrivacyRight )
			Edge( "right", new( w, 0, 0 ), 90, d - (s.Front ? fw : 0) );
		if ( s.PrivacyLeft || s.PrivacyRight )
		{
			var screen = MathF.Min( Math.Clamp( s.PrivacyThickness, .25f, 8 ), MathF.Min( w, d ) * .2f );
			var height = Math.Clamp( s.PrivacyHeight, 12, 192 );
			var length = d - (s.Front ? fw : 0);
			var material = context.Material( s.PrivacyMaterial ?? s.StructureMaterial );
			if ( s.PrivacyLeft )
				p.Box( "privacy_left", "Left Privacy Screen", material, new( 0, 0, t ), new( screen, length, t + height ) );
			if ( s.PrivacyRight )
				p.Box( "privacy_right", "Right Privacy Screen", material, new( w - screen, 0, t ), new( w, length, t + height ) );
		}

		if ( s.Brackets )
		{
			var drop = Math.Clamp( s.BracketDrop, 4, 128 );
			var reach = d * .8f;
			foreach ( var(id, x)in new[]
			{
				("left", w * .15f),
				("right", w * .85f)
			}

 )
				p.Add( "bracket_" + id, "Wall Bracket", p.Detail, kit =>
				{
					kit.Frame = new Transform( new Vector3( x, 0, s.Elevation ), Rotation.FromYaw( 90 ) );
					kit.Profile( [new( 0, -drop ), new( reach, -2 ), new( reach, 0 ), new( reach - 2, 0 ), new( 0, -drop + 2 )], -1, 1 );
				} );
		}

		StudioExtras.Balcony( p, s, w, d, t, context );
		AddExpansion( p, s, w, d, t, context );
		return p.Root;
	}
}

[ToolCategory( "Doors & Windows" ), Title( "Balcony Studio" ), Icon( "balcony" )]
public sealed partial class BalconyStudioEditor( PrimitiveTool tool ) : PartBuildingEditor( tool )
{
	readonly BalconyStudioGenerator generator = new();
	public BalconyStudioSettings Settings { get; } = new();
	protected override IPartGenerator Generator => generator;
	protected override GeneratorSettings Parts => Settings;

	public override bool ChangesOtherSettings( string property ) => property is nameof( BalconyStudioSettings.MountsEnabled ) or nameof( BalconyStudioSettings.MountBolts ) or nameof( BalconyStudioSettings.MountConnector ) or nameof( BalconyStudioSettings.FitColumnsToGround ) or nameof( BalconyStudioSettings.ManualGroundLength ) or nameof( BalconyStudioSettings.SeparateRailingStyles ) or nameof( BalconyStudioSettings.CornerBalcony ) or nameof( BalconyStudioSettings.SupportColumns ) or nameof( BalconyStudioSettings.FloorCovering ) or nameof( BalconyStudioSettings.RepeatFloors ) or nameof( BalconyStudioSettings.CanopyTopOnly ) or nameof( SnapToWall ) or nameof( CreateDoorOpenings ) or nameof( BalconyStudioSettings.Canopy ) or nameof( BalconyStudioSettings.CanopyPosts ) or nameof( BalconyStudioSettings.Style ) or nameof( BalconyStudioSettings.Brackets ) or nameof( BalconyStudioSettings.Front ) or nameof( BalconyStudioSettings.Left ) or nameof( BalconyStudioSettings.Right ) or nameof( BalconyStudioSettings.PrivacyLeft ) or nameof( BalconyStudioSettings.PrivacyRight ) or nameof( BalconyStudioSettings.FrontStyle ) or nameof( BalconyStudioSettings.LeftStyle ) or nameof( BalconyStudioSettings.RightStyle );
	protected override void Prepare()
	{
		Settings.Width = RectLength;
		Settings.Depth = RectDepth;
		Settings.StructureMaterial = PartTool.Pick( Settings.StructureMaterial, SurfaceMaterial );
		Settings.DetailMaterial = PartTool.Pick( Settings.DetailMaterial, TrimMaterial );
		Settings.FinishMaterial = PartTool.Pick( Settings.FinishMaterial, SurfaceMaterial );
	}
}