editor/AdvancedMappingTools/Architecture/FacadeSunshadeEditor.cs

Editor tool that generates a facade sunshade (louvered shading panel) from a two-point line. Defines settings, a part generator that builds frame, slats and wall plates, and the line editor that previews and places it.

namespace AdvancedMappingTools;
public sealed class FacadeSunshadeSettings : ArchitectureStudioSettings
{
	[Property, Group( "Sunshade" ), Range( 16, 512, slider: false ), WideMode]
	public float Height { get; set; } = 128;

	[Property, Group( "Sunshade" ), Title( "Projection" ), Range( 4, 96, slider: false ), WideMode]
	public float Projection { get; set; } = 24;

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

	[Property, Group( "Louvers" ), Title( "Requested Slats" ), Range( 1, 64, slider: false ), WideMode]
	public int Slats { get; set; } = 10;

	[Property, Group( "Louvers" ), Title( "Slat Angle" ), Range( -65, 65, slider: false ), WideMode]
	public float Angle { get; set; } = 25;

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

	[Property, Group( "Louvers" ), Title( "Minimum Gap" ), Range( .5f, 16, slider: false ), WideMode]
	public float MinimumGap { get; set; } = 2;

	[Property, Group( "Mounting" ), Title( "Wall Plates" ), WideMode]
	public bool WallPlates { get; set; } = true;

	[Property, Group( "Mounting" ), Title( "Plate Thickness" ), Range( .5f, 8, slider: false ), WideMode]
	public float PlateThickness { get; set; } = 2;

	/// <summary>Airfoil blades have a rounded outer nose; Flat blades are plain slabs.</summary>
	[Property, Group( "Louvers" ), Title( "Blade Profile" ), WideMode]
	public SunshadeBladeProfile BladeProfile { get; set; } = SunshadeBladeProfile.Airfoil;

	/// <summary>Horizontal blades are stacked up the height; vertical blades stand side by side along the width.</summary>
	[Property, Group( "Louvers" ), Title( "Blade Orientation" ), WideMode]
	public SunshadeOrientation Orientation { get; set; } = SunshadeOrientation.Horizontal;

	[Property, Group( "Louvers" ), Title( "Sizing Result" ), WideMode]
	public string SizingResult => $"{Resolve().Count} slats fitted without overlap";

	internal (float Width, float Height, float Frame, float Depth, float Thick, float Sin, float Cos, float Span, int Count) Resolve()
	{
		var w = MathF.Max( Width, 16 );
		var h = Math.Clamp( Height, 16, 512 );
		// Vertical blades are built like horizontal ones on a turned frame: the width becomes the stacking direction.
		if ( Orientation == SunshadeOrientation.Vertical )
			(w, h) = (h, w);
		var frame = MathF.Min( Math.Clamp( FrameWidth, 1, 16 ), MathF.Min( w, h ) * .2f );
		var angle = Math.Clamp( Angle, -65, 65 ) * MathF.PI / 180;
		var sin = MathF.Sin( angle );
		var cos = MathF.Cos( angle );
		var available = h - 2 * frame;
		var depth = MathF.Min( Math.Clamp( Projection, 4, 96 ), available * .5f );
		var thick = MathF.Min( Math.Clamp( SlatThickness, .25f, 8 ), available * .1f );
		var span = depth * MathF.Abs( sin ) + thick * cos;
		var count = Math.Clamp( Math.Min( Math.Clamp( Slats, 1, 64 ), (int)MathF.Floor( available / (span + Math.Clamp( MinimumGap, .5f, 16 )) ) ), 1, 64 );
		return (w, h, frame, depth, thick, sin, cos, span, count);
	}

	public override IReadOnlySet<string> UnusedOptions
	{
		get
		{
			var hidden = new HashSet<string>( base.UnusedOptions );
			if ( !WallPlates )
				hidden.UnionWith( [nameof( PlateThickness ), nameof( DetailMaterial )] );
			return hidden;
		}
	}
}

public enum SunshadeBladeProfile
{
	Airfoil,
	Flat
}

public enum SunshadeOrientation
{
	Horizontal,
	Vertical
}

public sealed class FacadeSunshadeGenerator : IPartGenerator
{
	public string Id => "facade_sunshade";
	public string Title => "Facade Sunshade";
	public Type SettingsType => typeof(FacadeSunshadeSettings);

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (FacadeSunshadeSettings)settings;
		var p = new ArchitectureStudioPlan( context, s, Title );
		var(w, h, frame, depth, thick, sin, cos, span, count) = s.Resolve();
		var reach = depth * cos + thick * MathF.Abs( sin );
		p.Box( "frame_left", "Left Frame", p.Structure, Vector3.Zero, new( frame, reach, h ) );
		p.Box( "frame_right", "Right Frame", p.Structure, new( w - frame, 0, 0 ), new( w, reach, h ) );
		p.Box( "frame_top", "Top Frame", p.Structure, new( frame, 0, h - frame ), new( w - frame, reach, h ) );
		p.Box( "frame_bottom", "Bottom Frame", p.Structure, new( frame, 0, 0 ), new( w - frame, reach, frame ) );
		var pitch = (h - 2 * frame) / count;
		for ( var i = 0; i < count; i++ )
		{
			var z = frame + pitch * (i + .5f);
			Vector3 At( float x, float u, float normal ) => new( x, reach * .5f + u * cos - normal * sin, z + u * sin + normal * cos );
			if ( s.BladeProfile == SunshadeBladeProfile.Flat )
			{
				p.Add( $"slat_{i}", "Sunshade Slat", p.Finish, kit => kit.Hex( At( frame, -depth * .5f, -thick * .5f ), At( w - frame, -depth * .5f, -thick * .5f ), At( w - frame, depth * .5f, -thick * .5f ), At( frame, depth * .5f, -thick * .5f ), At( frame, -depth * .5f, thick * .5f ), At( w - frame, -depth * .5f, thick * .5f ), At( w - frame, depth * .5f, thick * .5f ), At( frame, depth * .5f, thick * .5f ) ) );
				continue;
			}

			p.Add( $"slat_{i}", "Sunshade Slat", p.Finish, kit =>
			{
				// A blade section: rounded nose outside, full thickness a third in, thin tail at the wall.
				var shape = Blade( depth, thick ).Select( q => { var v = At( 0, q.x, q.y ); return new Vector2( v.y, v.z ); } ).ToList();
				if ( Area( shape ) < 0 )
					shape.Reverse();
				kit.Frame = new Transform( Vector3.Zero, Rotation.FromYaw( 90 ) );
				kit.Profile( shape, -(w - frame), -frame );
			} );
		}

		if ( s.WallPlates )
		{
			var t = Math.Clamp( s.PlateThickness, .5f, 8 );
			p.Box( "plate_left", "Left Wall Plate", p.Detail, new( -frame * .5f, -t, 0 ), new( frame * 1.5f, 0, h ) );
			p.Box( "plate_right", "Right Wall Plate", p.Detail, new( w - frame * 1.5f, -t, 0 ), new( w + frame * .5f, 0, h ) );
		}

		// Turn the built frame so its length runs up the wall: (x, y, z) becomes (h - z, y, x).
		if ( s.Orientation == SunshadeOrientation.Vertical )
		{
			var turn = Rotation.FromPitch( -90 );
			foreach ( var part in p.Root.Children )
				part.Local = new Transform( turn * part.Local.Position + new Vector3( h, 0, 0 ), turn * part.Local.Rotation, part.Local.Scale );
		}

		return p.Root;
	}

	/// <summary>Outline of a louvre blade around its centre, u across the depth, n across the thickness.</summary>
	static List<Vector2> Blade( float depth, float thick )
	{
		const int Steps = 8;
		var top = new List<Vector2>();
		for ( var k = 0; k <= Steps; k++ )
		{
			var a = MathF.PI * k / Steps;
			var u = -MathF.Cos( a ) * depth * .5f;
			// along: 0 at the outer edge, 1 at the wall.
			var along = (depth * .5f - u) / depth;
			var swell = along < .35f ? MathF.Sqrt( MathF.Max( 0, 1 - MathF.Pow( (.35f - along) / .35f, 2 ) ) ) : 1 - .7f * (along - .35f) / .65f;
			top.Add( new( u, thick * .5f * MathF.Max( swell, .2f ) ) );
		}

		return [.. top, .. top.AsEnumerable().Reverse().Select( q => new Vector2( q.x, -q.y ) )];
	}

	static float Area( IReadOnlyList<Vector2> shape )
	{
		var area = 0.0f;
		for ( var i = 0; i < shape.Count; i++ )
		{
			var a = shape[i];
			var b = shape[(i + 1) % shape.Count];
			area += a.x * b.y - b.x * a.y;
		}

		return area * .5f;
	}
}

[ToolCategory( "Doors & Windows" ), Title( "Facade Sunshade" ), Icon( "blinds" )]
[Description( "Draw the width with two points. Adjust the louver angle, height and projection; Sizing Result shows how many slats fit." )]
public sealed class FacadeSunshadeEditor( PrimitiveTool tool ) : LineEditor( tool )
{
	readonly FacadeSunshadeGenerator generator = new();
	public FacadeSunshadeSettings Settings { get; } = new();
	public override object ExtraSettings => Settings;
	protected override bool PlacesObjects => true;

	public override bool ChangesOtherSettings( string property ) => property == nameof( FacadeSunshadeSettings.WallPlates );
	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 );
	}
}