An editor UI panel for the architecture tool, showing properties for the currently picked layer or picked faces. It watches ArchTool state each frame and rebuilds a scrollable inspector with header, context, merge settings, validation messages, actions and an expandable properties sheet and faces debug list.
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
// The properties of whatever layer is picked, wherever it was picked from and whichever subtool is
// up. Selecting in the stack used to open nothing unless Select happened to be the active tool -
// this is the surface that makes a tree click an edit.
[Dock( "Editor", "Layer Inspector", "tune" )]
public sealed class ArchLayerInspectorPanel : Widget
{
public const string DockName = "Layer Inspector";
readonly Layout body;
object trackedItem;
int trackedRevision = int.MinValue;
int trackedTarget = int.MinValue;
int trackedFaces = int.MinValue;
bool trackedActive;
public ArchLayerInspectorPanel( Widget parent ) : base( parent )
{
Layout = Layout.Column();
var scroll = new ScrollArea( this );
scroll.Canvas = new Widget( scroll ) { Layout = Layout.Column() };
scroll.Canvas.Layout.Margin = 8;
scroll.Canvas.Layout.Spacing = 4;
body = scroll.Canvas.Layout;
Layout.Add( scroll, 1 );
Rebuild();
}
public static void Open()
{
EditorWindow.DockManager.SetDockState( DockName, true );
EditorWindow.DockManager.RaiseDock( DockName );
}
[EditorEvent.Frame]
void OnFrame()
{
var tool = ArchTool.Active;
var item = tool?.Picked?.Item;
var target = tool?.InsertionTarget?.ItemId ?? 0;
var faces = tool?.DebugFaces.Count ?? 0;
if ( ReferenceEquals( item, trackedItem )
&& (tool is not null) == trackedActive
&& (tool?.Revision ?? int.MinValue) == trackedRevision
&& target == trackedTarget
&& faces == trackedFaces )
{
return;
}
trackedItem = item;
trackedActive = tool is not null;
trackedRevision = tool?.Revision ?? int.MinValue;
trackedTarget = target;
trackedFaces = faces;
Rebuild();
}
void Rebuild()
{
body.Clear( true );
if ( ArchTool.Active is not { } tool )
{
body.Add( ArchPartUi.Wrapped( "Pick the Architecture or Roads tool in the scene view to edit this scene's plan." ) );
body.AddStretchCell();
return;
}
// Faces lead when there are any: they are only ever picked deliberately, to be read out.
BuildFaces( tool );
if ( tool.Picked is not { Item: not null } picked || tool.LayerTree.Find( picked.Item ) is not { } node )
{
if ( tool.DebugFaces.Count == 0 )
{
body.Add( ArchPartUi.Wrapped( "Nothing picked. Choose a layer in the Plan Layers stack, or click one in the viewport." ) );
}
body.AddStretchCell();
return;
}
// Layer concerns only: what this is, where it stands, what it merges. Navigating to a child, framing
// one and grouping one are all the stack's job - it draws the tree, so a second flattened copy of it
// down here is one more surface saying the same thing in fewer words.
BuildHeader( tool, node );
BuildContext( tool, node );
BuildMerge( tool, node );
BuildValidation( tool, node );
BuildActions( tool );
BuildProperties( tool, picked );
body.AddStretchCell();
}
void BuildHeader( ArchTool tool, ArchLayerNode node )
{
var path = new Label( tool.LayerTree.Breadcrumb( node ) );
path.SetStyles( "color: rgba(255,255,255,0.45);" );
path.WordWrap = true;
body.Add( path );
var title = body.AddRow();
title.Spacing = 4;
var name = new LineEdit( node.Name );
name.TextEdited += text =>
{
if ( ArchKindsAsked.TryRename( node.Payload, text ) )
{
tool.Affect( node.Ref?.ItemId ?? 0 );
tool.Touch( "Rename Layer" );
}
};
title.Add( name, 1 );
if ( node.Ref is { } layer )
{
title.Add( Toggle( node.Enabled ? "toggle_on" : "toggle_off",
node.Enabled ? "Generating - click to exclude this layer and its effects" : "Excluded from generation",
node.Enabled, () => tool.ToggleLayerEnabled( layer ) ) );
title.Add( Toggle( node.Locked ? "lock" : "lock_open",
node.Locked ? "Locked - handles and property edits refuse" : "Unlocked",
node.Locked, () => tool.ToggleLayerLocked( layer ) ) );
}
var label = node.Payload is ArchCutPart { IsDamage: true } ? "Damage zone" : ArchKindsAsked.Label( node.Kind );
var kind = new Label( $"{label} · {node.Stage}" );
kind.SetStyles( "color: rgba(255,255,255,0.45);" );
body.Add( kind );
}
static IconButton Toggle( string icon, string tooltip, bool active, Action clicked )
{
var button = new IconButton( icon ) { ToolTip = tooltip, IconSize = 15, FixedSize = 22, IsActive = active };
button.OnClick = () => clicked();
return button;
}
// Where this layer stands and what it consumes - the sentence the placement bar says while placing.
void BuildContext( ArchTool tool, ArchLayerNode node )
{
var parent = node.Parent;
var group = ArchLayerGroups.Holding( tool.Plan, node.Ref?.ItemId ?? 0 );
body.AddSeparator();
body.Add( Line( "Parent", parent?.Name ?? "plan root" ) );
body.Add( Line( "Host", ArchLayerRules.HostOutput( parent?.Kind ?? ArchKind.Building ) ) );
if ( node.Floor != int.MinValue )
{
body.Add( Line( "Story", $"Level {node.Floor}" ) );
}
body.Add( Line( "Group", group?.Name ?? "none — it reaches the whole plan" ) );
if ( node.Ref is { } layer )
{
var target = new Button( "Place new layers in here", "playlist_add" )
{
Clicked = () => tool.InsertionTarget = layer
};
body.Add( target );
}
}
static Widget Line( string label, string value )
{
var holder = new Widget( null ) { Layout = Layout.Row() };
holder.Layout.Spacing = 4;
var name = new Label( label );
name.MinimumWidth = 72;
name.SetStyles( "color: rgba(255,255,255,0.45);" );
var text = new Label( value );
text.WordWrap = true;
holder.Layout.Add( name );
holder.Layout.Add( text, 1 );
return holder;
}
// The terms of the join, edited from either end of it: the connector that does the merging, or
// the group that is the merge. They are the same settings because the group is the relationship.
void BuildMerge( ArchTool tool, ArchLayerNode node )
{
var group = node.Payload as ArchSiteAssembly
?? ArchLayerGroups.Holding( tool.Plan, node.Ref?.ItemId ?? 0 );
if ( group is null || group.Kind == ArchAssemblyKind.Group )
{
return;
}
if ( node.Payload is not ArchSiteAssembly && node.Kind != ArchKind.Walkway )
{
return;
}
var merge = group.Merge;
Action changed = () => { tool.Affect( group.Id ); tool.Touch( "Merge Settings" ); };
body.AddSeparator();
body.Add( new Label( "MERGE" ) );
body.Add( ArchPartUi.Check( "Open the walls it meets", merge.OpenWalls, value => { merge.OpenWalls = value; changed(); Rebuild(); } ) );
if ( !merge.OpenWalls )
{
return;
}
body.Add( ArchPartUi.Check( "Near end", merge.NearEnd, value => { merge.NearEnd = value; changed(); } ) );
body.Add( ArchPartUi.Check( "Far end", merge.FarEnd, value => { merge.FarEnd = value; changed(); } ) );
body.Add( ArchPartUi.Number( "Mouth height", merge.Clearance, 0f, value => { merge.Clearance = value; changed(); } ) );
}
// Folded away by default: the part sheet is the authoring tool's surface, not the layer's, and
// the choice sticks so nobody has to re-open it every time the selection changes.
void BuildProperties( ArchTool tool, ArchSelection picked )
{
body.AddSeparator();
var open = Expanded;
var toggle = new Button( picked.Describe(), open ? "expand_less" : "expand_more" );
toggle.Clicked = () => { Expanded = !open; Rebuild(); };
body.Add( toggle );
if ( !open )
{
return;
}
var sheet = new ToolSidebarWidget( null );
ArchPartUi.Sheet( sheet, tool, picked, Rebuild );
body.Add( sheet );
}
static bool Expanded
{
get => EditorCookie.Get( "arch.inspector.properties", false );
set => EditorCookie.Set( "arch.inspector.properties", value );
}
// The picked faces, and the one button that hands them over. What it copies is what arch_faces
// returns, so the paste and the query are the same report. Folds away because it sits above the
// layer's own properties and a dozen face lines would push them off the panel.
void BuildFaces( ArchTool tool )
{
if ( tool.DebugFaces.Count == 0 )
{
return;
}
var open = FacesExpanded;
var head = body.AddRow();
head.Spacing = 4;
head.Add( Toggle( open ? "expand_less" : "expand_more",
open ? "Hide the picked faces" : "Show the picked faces",
open, () => { FacesExpanded = !open; Rebuild(); } ) );
head.Add( new Label( $"FACES · {tool.DebugFaces.Count} picked" ), 1 );
var brief = new IconButton( "smart_toy" )
{
ToolTip = "Copy the briefing - every picked face keyed by id, with a verdict. The form to paste to an agent.",
IconSize = 15,
FixedSize = 22
};
brief.OnClick = () => Log.Info( tool.CopyFaceDebug( true ) );
head.Add( brief );
var copy = new IconButton( "content_copy" )
{
ToolTip = "Copy the plain report - the same facts written out in prose",
IconSize = 15,
FixedSize = 22
};
copy.OnClick = () => Log.Info( tool.CopyFaceDebug() );
head.Add( copy );
if ( !open )
{
body.AddSeparator();
return;
}
foreach ( var face in tool.DebugFaces.Take( 12 ) )
{
var line = new Label( $"{face.Piece.Split( '/' ).Last()} · {face.Label}" );
line.WordWrap = true;
line.SetStyles( "color: rgba(255,180,170,0.9);" );
body.Add( line );
}
if ( tool.DebugFaces.Count > 12 )
{
body.Add( new Label( $"… and {tool.DebugFaces.Count - 12} more" ) );
}
body.Add( new Button.Primary( "Copy Briefing", "smart_toy" ) { Clicked = () => Log.Info( tool.CopyFaceDebug( true ) ) } );
body.Add( new Button( "Copy Debug Info Plain", "content_copy" ) { Clicked = () => Log.Info( tool.CopyFaceDebug() ) } );
body.AddSeparator();
}
static bool FacesExpanded
{
get => EditorCookie.Get( "arch.inspector.faces", true );
set => EditorCookie.Set( "arch.inspector.faces", value );
}
void BuildValidation( ArchTool tool, ArchLayerNode node )
{
var problems = tool.LayerTree.Problems( node );
if ( problems.Count == 0 )
{
return;
}
body.AddSeparator();
foreach ( var problem in problems )
{
var warning = new Label( $"⚠ {problem}" );
warning.WordWrap = true;
warning.SetStyles( "color: #e0b040;" );
body.Add( warning );
}
}
// Delete alone. Framing and grouping are gestures on the tree - a row's own context menu and the stack's
// header buttons - and having them twice only raises the question of whether the two do the same thing.
void BuildActions( ArchTool tool )
{
body.AddSeparator();
var row = body.AddRow();
row.AddStretchCell();
row.Add( new Button.Danger( "Delete", "delete" ) { Clicked = () => tool.DeleteSelected() } );
}
}