Editor subtool for placing pillar architecture. Provides UI for single or grid placement, previews ghosts, browses pillar types, seeds drafts, and commits placed ArchPillarPart instances into a room.
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
[Title( "Pillars" ), Icon( "view_column" ), Group( "09" )]
public sealed class ArchPillarSubtool( ArchTool owner ) : ArchSubtool( owner )
{
protected override ArchKind? DraftKind => ArchKind.Pillar;
public override ArchSurface[] Surfaces => new[] { ArchSurface.Pillar, ArchSurface.PillarCap };
enum PillarGesture
{
Single,
Grid
}
ArchPillarPart draft = new();
ArchPresetBrowser<ArchPillarType> browser;
PillarGesture gesture = PillarGesture.Single;
float maxSpacing;
protected override bool UsesDrag => gesture == PillarGesture.Grid;
// Only over a column. The selection outlives the tool that made it, so an unconditional yes would put another
// kind's widgets on screen here, and the gizmo takes the press before any placement gesture sees it.
protected override bool Adjusts => Owner.Picked?.Item is ArchPillarPart;
protected override string Title() => "Pillars";
protected override string Advice() => gesture switch
{
PillarGesture.Grid => "Drag the extent. Bays divide it evenly, so a shorter drag gives shorter spans, and each column climbs to whatever stands over it.",
_ => "Click to stand one column. It climbs to whatever stands over it, and the widgets on it move, turn and section it afterwards."
};
// Seeded on first use - the active building isn't known until the tool is up.
ArchPillarType Kind => Owner.FindPillarType( draft.Type ) ?? Owner.DefaultPillar();
void Seeded()
{
if ( draft.Type.Length > 0 || Owner.DefaultPillar() is not { } first )
{
return;
}
draft = first.Standing( default, 0f, 0f );
maxSpacing = MathF.Max( draft.Spacing.x, draft.Spacing.y );
}
void Adopt( ArchPillarType type )
{
draft = type.Standing( default, 0f, 0f );
maxSpacing = MathF.Max( draft.Spacing.x, draft.Spacing.y );
Refresh();
}
protected override void DrawHover( Vector2 point )
{
if ( gesture == PillarGesture.Grid )
{
base.DrawHover( point );
return;
}
Seeded();
Ghost( point, point, Owner.RoomOnLevel( point ), Lone() );
}
protected override void DrawPreview()
{
Seeded();
Ghost( DragStart, DragCurrent, Owner.RoomOnLevel( DragStart ), Bays( DragCurrent - DragStart ) );
}
void Ghost( Vector2 origin, Vector2 note, ArchRoom room, Grid bays )
{
var standing = Standing( origin, bays, room );
var height = ArchPillarGen.Height( standing, room, Owner.Kit, Owner.Plan );
var lift = Lift( room );
var half = standing.Half;
foreach ( var column in ArchPillarGen.Columns( standing ) )
{
var seat = new Vector2( column.At.x, column.At.y );
var foot = column.At.z + lift;
ArchGhost.Volume( seat - half, seat + half, foot, foot + height );
}
ArchGhost.Note( new Vector3( note.x, note.y, standing.BaseHeight + lift + height ), Measured( standing ) );
}
// The plan is authored flat and the building is poured onto its grade, so a ghost drawn at plan height stands
// a foundation below the column it is previewing.
float Lift( ArchRoom room )
{
var host = Owner.Plan.OwnerOf( room ) ?? Owner.ActiveBuilding();
return host is null ? 0f : ArchAsks.Lift( Owner.Plan, host, Owner.Kit );
}
string Measured( ArchPillarPart standing )
{
var title = Kind?.Title ?? "pillars";
if ( standing.CountX <= 1 && standing.CountY <= 1 )
{
return $"{title} — one column, {standing.Width:0} x {standing.Depth:0}";
}
return $"{title} — {standing.CountX} x {standing.CountY} · {MathF.Abs( standing.Spacing.x ):0} x {MathF.Abs( standing.Spacing.y ):0} bays";
}
protected override void OnClick( Vector2 point )
{
Place( point, Lone() );
}
protected override void OnDrag( Vector2 from, Vector2 to )
{
Place( from, Bays( to - from ) );
}
void Place( Vector2 at, Grid bays )
{
Seeded();
var room = Owner.RoomOnLevel( at );
if ( room is null || Kind is null )
{
return;
}
var part = Standing( at, bays, room );
part.Id = Owner.Plan.AllocateId();
part.Name = $"Pillars{room.Pillars.Count + 1}";
room.Pillars.Add( part );
FinishDraft( part.Id );
Owner.Picked = new ArchSelection { Item = part, Room = room };
Drew( part );
Owner.Commit();
}
// ONE resolution the ghost and the placed part both read. The ghost laid its own grid out off the raw drag,
// so a turned column previewed square and landed skewed, and it took a height nothing else measured.
// The DRAFT, not the type, supplies the section - sidebar tuning before the drag stands.
ArchPillarPart Standing( Vector2 from, Grid bays, ArchRoom room )
{
var part = draft.Section();
part.Type = Kind?.Name ?? draft.Type;
part.Origin = from;
part.BaseHeight = room?.BaseHeight ?? Owner.LevelHeight;
part.Placement = PillarPlacement.Grid;
part.CountX = bays.CountX;
part.CountY = bays.CountY;
part.Spacing = bays.Spacing;
return part;
}
// A lone column keeps the draft's bay so the volume widget has a rung to divide by the moment it is pulled wider.
Grid Lone() => new( 1, 1, draft.Spacing );
readonly record struct Grid( int CountX, int CountY, Vector2 Spacing );
// Spacing is a maximum - bays divide the reach so the last column lands on release.
Grid Bays( Vector2 reach )
{
var limit = MathF.Max( 32f, maxSpacing > 1f ? maxSpacing : MathF.Max( draft.Spacing.x, draft.Spacing.y ) );
var alongX = Owner.Grid.DivideAtMost( Dragged( reach.x ), limit );
var alongY = Owner.Grid.DivideAtMost( Dragged( reach.y ), limit );
return new Grid(
alongX.Count + 1,
alongY.Count + 1,
new Vector2( MathF.Sign( reach.x ) * alongX.Step, MathF.Sign( reach.y ) * alongY.Step ) );
}
// A sub-inch reach is one column, not two an inch apart.
static float Dragged( float reach ) => MathF.Abs( reach ) < 1f ? 0f : MathF.Abs( reach );
protected override void BuildOptions( ToolSidebarWidget panel )
{
Seeded();
BuildGesture( panel );
Browse( panel );
ArchSidebarSection.Show( panel, Scope( "placement" ), "Placement", gesture == PillarGesture.Grid, group =>
group.Add( ArchPartUi.Number( "Max bay", maxSpacing, 240f, value => maxSpacing = value ) ) );
// Section, footing and span ARE the pillar type, authored in the designer the browser's own header
// reaches. Laid out open they were the whole panel, above the cards that already show them.
ArchSidebarSection.Disclosure( panel, Scope( "type" ), "Modify type", true, group =>
ArchSidebarLayout.Form( group, form => ArchPillarUi.Build( form, draft, Kind, Refresh, null ) ) );
}
void BuildGesture( ToolSidebarWidget panel )
{
ArchSidebarSection.Show( panel, Scope( "gesture" ), "Gesture", group =>
{
using var grid = ArchIconGrid.In( group );
grid.Pick( "Single — click to stand one column", "pillar_single", "view_column",
gesture == PillarGesture.Single, () => Switch( PillarGesture.Single ) );
grid.Pick( "Grid — drag a colonnade, bays dividing the reach", "pillar_grid", "grid_on",
gesture == PillarGesture.Grid, () => Switch( PillarGesture.Grid ) );
} );
}
void Switch( PillarGesture wanted )
{
gesture = wanted;
Refresh();
}
// The designer rides the browser header, the way every other authored kit reaches its designer.
void Browse( ToolSidebarWidget panel )
{
var offered = Offered();
if ( offered.Count == 0 )
{
panel.Layout.Add( ArchSidebarLayout.Advice( $"No pillar types are authored — add one to Assets/{ArchPillarCatalog.Directory}." ) );
return;
}
browser = new ArchPresetBrowser<ArchPillarType>( panel, Owner.Kit, Scope( "types" ), "Pillar types" )
{
Chosen = type => string.Equals( draft.Type, type.Name, StringComparison.OrdinalIgnoreCase ),
Choose = Adopt,
Design = () => new ArchPillarDesigner( panel, Owner, draft, Kind, ApplyDesigned ).Show(),
Reload = () =>
{
Owner.ReloadPillarTypes();
Refresh();
}
};
browser.DesignButton.Visible = true;
browser.DesignButton.ToolTip = "Design a pillar type";
browser.ReloadButton.Visible = true;
browser.Set( offered );
panel.Layout.Add( browser );
}
List<ArchPresetItem<ArchPillarType>> Offered()
{
var height = Owner.Kit.WallHeight;
return Owner.OfferedPillars()
.Select( type => new ArchPresetItem<ArchPillarType>
{
Value = type,
Name = type.Title,
Detail = ArchPillarStage.Describe( type ),
Identity = ArchPillarStage.Identity( type.Standing( default, 0f, 0f ), height ),
Category = type.Column?.Span switch
{
PillarSpan.Arch => "Arcade",
PillarSpan.Beam => "Beam",
_ => "Post"
},
Glyph = type.Icon,
Tags = $"{type.Name} {type.Description}",
View = ArchPresetPreview.Quarter,
Focus = ArchPreviewFocus.Whole,
Recipe = () => ArchPillarStage.Of( type.Standing( default, 0f, 0f ), height )
} )
.ToList();
}
void ApplyDesigned( ArchPillarPart designed )
{
draft = designed;
maxSpacing = MathF.Max( draft.Spacing.x, draft.Spacing.y );
Refresh();
}
}