editor/AdvancedMappingTools/Architecture/BillboardEditor.cs

Editor tool that generates a billboard prop. BillboardSettings holds width, height, bottom height, post layout, lamps, double-sided and walkway options; BillboardGenerator builds the panel, frame, cross beams, posts or wall brackets, walkway and railing from boxes and beams; BillboardEditor wires it into the primitive tool under the Street category.

namespace AdvancedMappingTools;

public enum BillboardPosts
{
	[Title( "One Post" )] Single,
	[Title( "Two Posts" )] Double,
	[Title( "Wall Mounted" )] Wall
}

public sealed class BillboardSettings : PropSettings
{
	[Property, Group( "Billboard" ), Range( 16, 512, slider: false ), WideMode]
	public float Width { get; set; } = 160.0f;

	[Property, Group( "Billboard" ), Range( 16, 256, slider: false ), WideMode]
	public float Height { get; set; } = 80.0f;

	[Property, Group( "Billboard" ), Title( "Bottom" ), Range( 8, 256, slider: false ), WideMode, Description( "Height of the lower edge of the panel above the ground." )]
	public float Bottom { get; set; } = 60.0f;

	[Property, Group( "Billboard" ), Title( "Posts" ), WideMode]
	public BillboardPosts Posts { get; set; } = BillboardPosts.Double;

	[Property, Group( "Billboard" ), Title( "Lamps" ), WideMode, Description( "Lamps on arms above the panel that shine down on it." )]
	public bool Lamps { get; set; } = true;

	[Property, Group( "Billboard" ), Title( "Both Sides" ), WideMode, Description( "A second panel on the back, with its own frame and lamps. Not for a billboard on a wall." )]
	public bool DoubleSided { get; set; } = false;

	[Property, Group( "Billboard" ), Title( "Walkway" ), WideMode, Description( "A service walkway with a railing in front of the panel, below its lower edge." )]
	public bool Walkway { get; set; } = true;

	public override string PropName => "Billboard";
}

/// <summary>
/// A billboard as it is built: the panel in a frame, cross beams behind it, square steel posts on
/// base plates or brackets against a wall, a service walkway with a railing and lamps on arms.
/// The front faces -Y. No part reaches into another.
/// </summary>
public sealed class BillboardGenerator : PropGenerator
{
	public override string Id => "billboard";
	public override string Title => "Billboard";
	public override Type SettingsType => typeof( BillboardSettings );

	public const float FrameWidth = 4.0f;
	const float PanelHalf = 1.0f;
	const float FrameHalf = 2.0f;
	const float BeamDepth = 4.0f;
	const float PostRadius = 4.0f;
	const float WalkwayDepth = 28.0f;
	const float RailHeight = 36.0f;

	protected override void Build( PropSettings settings, PropBuilder b )
	{
		var s = (BillboardSettings)settings;
		var w = MathF.Max( s.Width, 16.0f ) * 0.5f;
		var h = MathF.Max( s.Height, 16.0f );
		var bottom = MathF.Max( s.Bottom, 8.0f );
		var top = bottom + h;

		// The back structure between the faces: beams right behind the frame, posts behind the beams.
		var beamFront = FrameHalf;
		var beamBack = beamFront + BeamDepth;
		var radius = s.Posts == BillboardPosts.Single ? PostRadius * 1.5f : PostRadius;
		var postY = beamBack + radius;
		var both = s.DoubleSided && s.Posts != BillboardPosts.Wall;
		var backFace = both ? postY + radius + BeamDepth + FrameHalf : 0.0f;

		Face( b, "front", -1.0f, 0.0f, w, h, bottom, s.Lamps );
		if ( both ) Face( b, "back", 1.0f, backFace, w, h, bottom, s.Lamps );

		// Cross beams carry the panel, a lower and an upper one.
		foreach ( var (name, z) in new[] { ("beam_low", bottom + h * 0.25f), ("beam_high", bottom + h * 0.75f) } )
		{
			b.Box( name, "Cross Beam", b.Metal, new Vector3( -w, beamFront, z - 3 ), new Vector3( w, beamBack, z + 3 ) );
			if ( both )
				b.Box( name + "_back", "Cross Beam", b.Metal, new Vector3( -w, backFace - FrameHalf - BeamDepth, z - 3 ), new Vector3( w, backFace - FrameHalf, z + 3 ) );
		}

		var postTop = bottom + h * 0.75f + 3.0f;

		switch ( s.Posts )
		{
			case BillboardPosts.Single:
				Post( b, "post", 0.0f, postY, radius, postTop );
				break;

			case BillboardPosts.Double:
				Post( b, "post_left", -w * 0.55f, postY, PostRadius, postTop );
				Post( b, "post_right", w * 0.55f, postY, PostRadius, postTop );
				break;

			default:
				// Brackets from the cross beams back to the wall, which stands at the back of the posts.
				foreach ( var (name, x) in new[] { ("bracket_left", -w * 0.6f), ("bracket_right", w * 0.6f) } )
					foreach ( var (level, z) in new[] { ("low", bottom + h * 0.25f), ("high", bottom + h * 0.75f) } )
						b.Box( $"{name}_{level}", "Wall Bracket", b.Metal, new Vector3( x - 2, beamBack, z - 2 ), new Vector3( x + 2, beamBack + 12, z + 2 ) );
				break;
		}

		// The walkway brackets end on the front face of the posts.
		var postFace = postY - radius;
		if ( s.Walkway && s.Posts != BillboardPosts.Wall ) Walkway( b, w, bottom, postFace, s.Posts == BillboardPosts.Single ? [0.0f] : [-w * 0.55f, w * 0.55f] );
	}

	/// <summary>
	/// Panel, frame and lamps of one face. <paramref name="facing"/> is -1 for the front, +1 for the back.
	/// </summary>
	static void Face( PropBuilder b, string id, float facing, float at, float w, float h, float bottom, bool lamps )
	{
		var top = bottom + h;
		// Positive y points out of the face, towards whoever looks at it.
		float Y( float y ) => at + facing * y;
		Vector3 V( float x, float y, float z ) => new( x, Y( y ), z );
		void Box( string part, string label, Material m, float x0, float y0, float z0, float x1, float y1, float z1 ) =>
			b.Box( $"{id}_{part}", label, m, V( x0, y0, z0 ), V( x1, y1, z1 ) );

		Box( "panel", "Panel", b.Detail, -w, -PanelHalf, bottom, w, PanelHalf, top );
		Box( "frame_top", "Frame", b.Metal, -w - FrameWidth, -FrameHalf, top, w + FrameWidth, FrameHalf, top + FrameWidth );
		Box( "frame_bottom", "Frame", b.Metal, -w - FrameWidth, -FrameHalf, bottom - FrameWidth, w + FrameWidth, FrameHalf, bottom );
		Box( "frame_left", "Frame", b.Metal, -w - FrameWidth, -FrameHalf, bottom, -w, FrameHalf, top );
		Box( "frame_right", "Frame", b.Metal, w, -FrameHalf, bottom, w + FrameWidth, FrameHalf, top );

		if ( !lamps ) return;

		// The arms start on the top of the frame and lean forward, the heads point back at the panel.
		var count = Math.Clamp( (int)MathF.Round( w * 2.0f / 60.0f ), 2, 8 );
		for ( var i = 0; i < count; i++ )
		{
			var x = -w + (i + 0.5f) * (w * 2.0f / count);
			var root = top + FrameWidth;
			b.Part( $"{id}_lamp_arm{i}", "Lamp Arm", b.Metal, kit => kit.Beam( V( x, 0.0f, root + 1.0f ), V( x, 24.0f, root + 10.0f ), 2.0f, 2.0f ) );
			Box( $"lamp{i}", "Lamp", b.Metal, x - 6, 24.0f, root + 11.0f, x + 6, 34.0f, root + 16.0f );
		}
	}

	static void Post( PropBuilder b, string id, float x, float y, float radius, float top )
	{
		b.Box( id + "_plate", "Base Plate", b.Metal, new Vector3( x - radius * 2.2f, y - radius * 2.2f, 0.0f ), new Vector3( x + radius * 2.2f, y + radius * 2.2f, 1.5f ) );
		// Square steel tube: its faces meet beams and brackets flat.
		b.Box( id, "Post", b.Metal, new Vector3( x - radius, y - radius, 1.5f ), new Vector3( x + radius, y + radius, top ) );
	}

	/// <summary>
	/// A grating in front of the panel just under its lower edge, on brackets reaching back to the
	/// cross beam level, with a railing along its front.
	/// </summary>
	static void Walkway( PropBuilder b, float w, float bottom, float reach, float[] brackets )
	{
		var deck = bottom - FrameWidth - 2.0f;
		var front = -FrameHalf - WalkwayDepth;

		b.Box( "walkway", "Walkway", b.Metal, new Vector3( -w, front, deck - 2.0f ), new Vector3( w, -FrameHalf, deck ) );

		for ( var i = 0; i < brackets.Length; i++ )
			b.Box( $"walkway_bracket{i}", "Walkway Bracket", b.Metal, new Vector3( brackets[i] - 2, front, deck - 6.0f ), new Vector3( brackets[i] + 2, reach, deck - 2.0f ) );

		var posts = Math.Max( 2, (int)MathF.Ceiling( w * 2.0f / 48.0f ) + 1 );
		for ( var i = 0; i < posts; i++ )
		{
			var x = -w + 1.0f + i * ((w * 2.0f - 2.0f) / (posts - 1));
			b.Box( $"rail_post{i}", "Railing Post", b.Metal, new Vector3( x - 1, front, deck ), new Vector3( x + 1, front + 2, deck + RailHeight - 2 ) );
		}

		b.Box( "rail_top", "Railing", b.Metal, new Vector3( -w, front, deck + RailHeight - 2 ), new Vector3( w, front + 2, deck + RailHeight ) );
		// The middle rail runs in front of the posts, not through them.
		b.Box( "rail_mid", "Railing", b.Metal, new Vector3( -w, front - 1.0f, deck + RailHeight * 0.5f - 1 ), new Vector3( w, front, deck + RailHeight * 0.5f + 1 ) );
	}
}

/// <summary>
/// Places a billboard. One click, the panel stands on its posts.
/// </summary>
[ToolCategory( "Street" )]
[Title( "Billboard" ), Icon( "campaign" )]
public sealed class BillboardEditor( PrimitiveTool tool ) : PropEditor( tool )
{
	readonly BillboardGenerator _generator = new();

	public BillboardSettings Settings { get; } = new();

	protected override IPartGenerator Generator => _generator;

	public override PropSettings Props => Settings;
}