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