editor/AdvancedMappingTools/Architecture/SolarArrayEditor.cs

Editor tool that generates a solar array: a grid of tilted panels on a shared mounting plinth, with settings for rows, columns, tilt, frame, cell grid and supports. Draws the geometry through the architecture studio part plan.

namespace AdvancedMappingTools;
/// <summary>A drawn array of closed panels on supports connected to a common plinth.</summary>
public sealed class SolarArraySettings : ArchitectureStudioSettings
{
	[Property, Group( "Layout" ), Range( 1, 8, slider: false ), WideMode]
	public int Columns { get; set; } = 3;

	[Property, Group( "Layout" ), Range( 1, 8, slider: false ), WideMode]
	public int Rows { get; set; } = 2;

	[Property, Group( "Layout" ), Title( "Module Gap" ), Range( 1, 32, slider: false ), WideMode]
	public float ModuleGap { get; set; } = 8;

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

	[Property, Group( "Panels" ), Title( "Panel Thickness" ), Range( .5f, 8, slider: false ), WideMode]
	public float PanelThickness { get; set; } = 1;

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

	[Property, Group( "Cells" ), Title( "Show Cell Grid" ), WideMode]
	public bool Cells { get; set; } = true;

	[Property, Group( "Cells" ), Title( "Cell Columns" ), Range( 1, 12, slider: false ), WideMode]
	public int CellColumns { get; set; } = 4;

	[Property, Group( "Cells" ), Title( "Cell Rows" ), Range( 1, 12, slider: false ), WideMode]
	public int CellRows { get; set; } = 6;

	[Property, Group( "Cells" ), Title( "Cell Joint" ), Range( .1f, 4, slider: false ), WideMode]
	public float CellJoint { get; set; } = .5f;

	[Property, Group( "Mounting" ), Title( "Mount Height" ), Range( 4, 128, slider: false ), WideMode]
	public float MountHeight { get; set; } = 12;

	[Property, Group( "Mounting" ), Title( "Support Width" ), Range( 1, 16, slider: false ), WideMode]
	public float SupportWidth { get; set; } = 2;

	[Property, Group( "Mounting" ), Title( "Base Thickness" ), Range( 1, 16, slider: false ), WideMode]
	public float BaseThickness { get; set; } = 2;

	/// <summary>Two rails across the underside of each panel; off puts the posts straight under the panel.</summary>
	[Property, Group( "Mounting" ), Title( "Mounting Rails" ), WideMode]
	public bool MountingRails { get; set; } = true;

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

public sealed class SolarArrayGenerator : IPartGenerator
{
	public string Id => "solar_array";
	public string Title => "Solar Array";
	public Type SettingsType => typeof(SolarArraySettings);

	public PartPlan Generate( GeneratorSettings settings, GenContext context )
	{
		var s = (SolarArraySettings)settings;
		var p = new ArchitectureStudioPlan( context, s, Title );
		var width = MathF.Max( s.Width, 16 );
		var depth = MathF.Max( s.Depth, 16 );
		var columns = Math.Clamp( s.Columns, 1, 8 );
		var rows = Math.Clamp( s.Rows, 1, 8 );
		var slotX = width / columns;
		var slotY = depth / rows;
		var gap = MathF.Min( Math.Clamp( s.ModuleGap, 1, 32 ), MathF.Min( slotX, slotY ) * .25f );
		var panelWidth = slotX - gap;
		var projectedDepth = slotY - gap;
		var angle = Math.Clamp( s.Tilt, 0, 65 ) * MathF.PI / 180;
		var sin = MathF.Sin( angle );
		var cos = MathF.Cos( angle );
		var length = projectedDepth / cos;
		var thick = MathF.Min( Math.Clamp( s.PanelThickness, .5f, 8 ), MathF.Min( panelWidth, length ) * .1f );
		var border = MathF.Min( Math.Clamp( s.FrameWidth, .5f, 8 ), MathF.Min( panelWidth, length ) * .2f );
		var baseHeight = Math.Clamp( s.BaseThickness, 1, 16 );
		var mount = baseHeight + Math.Clamp( s.MountHeight, 4, 128 );
		p.Box( "base", "Shared Mounting Plinth", p.Structure, Vector3.Zero, new( width, depth, baseHeight ) );
		for ( var row = 0; row < rows; row++ )
			for ( var col = 0; col < columns; col++ )
			{
				var id = $"module_{row}_{col}";
				var x = col * slotX + gap * .5f;
				var y = row * slotY + gap * .5f;
				Vector3 At( float a, float b, float normal ) => new( x + a, y + b * cos - normal * sin, mount + b * sin + normal * cos );
				void PanelBox( MeshKit kit, float x0, float x1, float u0, float u1, float low, float high ) => kit.Hex( At( x0, u0, low ), At( x1, u0, low ), At( x1, u1, low ), At( x0, u1, low ), At( x0, u0, high ), At( x1, u0, high ), At( x1, u1, high ), At( x0, u1, high ) );
				p.Add( id + "_body", "Panel Backing", p.Detail, kit => PanelBox( kit, 0, panelWidth, 0, length, 0, thick ) );
				p.Add( id + "_frame", "Panel Frame", p.Detail, kit =>
				{
					PanelBox( kit, 0, panelWidth, 0, border, thick, thick * 1.5f );
					PanelBox( kit, 0, panelWidth, length - border, length, thick, thick * 1.5f );
					PanelBox( kit, 0, border, border, length - border, thick, thick * 1.5f );
					PanelBox( kit, panelWidth - border, panelWidth, border, length - border, thick, thick * 1.5f );
				} );
				p.Add( id + "_cells", "Solar Cells", p.Finish, kit =>
				{
					var nx = s.Cells ? Math.Clamp( s.CellColumns, 1, 12 ) : 1;
					var ny = s.Cells ? Math.Clamp( s.CellRows, 1, 12 ) : 1;
					var sx = (panelWidth - 2 * border) / nx;
					var sy = (length - 2 * border) / ny;
					var joint = s.Cells ? MathF.Min( Math.Clamp( s.CellJoint, .1f, 4 ), MathF.Min( sx, sy ) * .25f ) : 0;
					for ( var a = 0; a < nx; a++ )
						for ( var b = 0; b < ny; b++ )
							PanelBox( kit, border + a * sx + joint * .5f, border + (a + 1) * sx - joint * .5f, border + b * sy + joint * .5f, border + (b + 1) * sy - joint * .5f, thick, thick * 1.25f );
				} );
				// Two rails run across the underside of the panel; the posts carry the rails.
				var size = MathF.Min( Math.Clamp( s.SupportWidth, 1, 16 ), MathF.Min( panelWidth, projectedDepth ) * .15f );
				var railHeight = s.MountingRails ? MathF.Min( size, Math.Clamp( s.MountHeight, 4, 128 ) * cos * .5f ) : 0;
				var posts = new[] { projectedDepth * .2f, projectedDepth * .8f };
				if ( s.MountingRails )
					p.Add( id + "_rails", "Mounting Rails", p.Structure, kit =>
					{
						foreach ( var b in posts )
						{
							var center = (b - railHeight * sin) / cos;
							var half = size * .75f / cos;
							PanelBox( kit, 0, panelWidth, center - half, center + half, -railHeight, 0 );
						}
					} );
				p.Add( id + "_supports", "Panel Supports", p.Structure, kit =>
				{
					foreach ( var a in new[] { panelWidth * .2f, panelWidth * .8f } )
						foreach ( var b in posts )
						{
							var x0 = x + a - size * .5f;
							var x1 = x + a + size * .5f;
							var y0 = y + b - size * .5f;
							var y1 = y + b + size * .5f;
							// Sloped post ends exactly follow the underside of the rail.
							var z0 = mount + (y0 - y) * sin / cos - railHeight / cos;
							var z1 = mount + (y1 - y) * sin / cos - railHeight / cos;
							kit.Hex( new( x0, y0, baseHeight ), new( x1, y0, baseHeight ), new( x1, y1, baseHeight ), new( x0, y1, baseHeight ), new( x0, y0, z0 ), new( x1, y0, z0 ), new( x1, y1, z1 ), new( x0, y1, z1 ) );
						}
				} );
			}

		return p.Root;
	}
}

[ToolCategory( "Roofs" ), Title( "Solar Array" ), Icon( "solar_power" )]
[Description( "Draw a rectangle with three points. Set rows, columns, panel tilt and cell details on a shared mounting plinth." )]
public sealed class SolarArrayEditor( PrimitiveTool tool ) : PartBuildingEditor( tool )
{
	readonly SolarArrayGenerator generator = new();
	public SolarArraySettings Settings { get; } = new();
	protected override IPartGenerator Generator => generator;
	protected override GeneratorSettings Parts => Settings;

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