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 );
}
}