Editor dock panel and UI components for the architecture "Plan Layers" panel. Provides a tree view of authored layers, drafts and built geometry, keeps the selection in sync with ArchTool picks, handles clicks, context menus, drag-and-drop reordering and grouping, and draws row UI (icons, controls, highlights, flashes).
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
using static Editor.BaseItemWidget;
namespace Sunless.Architecture;
// The Plan Layers stack: the authored plan is the navigation surface, arranged like a paint stack.
// Rows come from the projected layer tree, never from generated geometry; a row click drives the
// same selection funnel as a viewport pick, so tree and viewport can never disagree.
[Dock( "Editor", "Plan Layers", "layers" )]
public sealed class ArchLayersDockPanel : Widget
{
public const string DockName = "Plan Layers";
readonly ArchLayerStack stack;
readonly Layout header;
readonly Label breadcrumb;
ArchLayerTree projected = new();
HashSet<int> built = new();
// A row click has already set the selection the user asked for, ctrl and shift included, so the pick
// that follows it must not stamp one row back over the lot.
bool pickedInStack;
bool trackedActive;
bool trackedGeometry;
ArchPlan trackedPlan;
int trackedRevision;
int trackedDrafts;
int trackedAssemblies;
object trackedPicked;
public ArchLayersDockPanel( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Margin = 4;
Layout.Spacing = 4;
header = Layout.AddRow();
header.Spacing = 2;
breadcrumb = new Label( "Nothing picked" );
breadcrumb.WordWrap = true;
breadcrumb.MinimumHeight = 16;
Layout.Add( breadcrumb );
stack = new ArchLayerStack( this );
stack.ItemClicked += OnItemClicked;
Layout.Add( stack, 1 );
BuildHeader();
}
public static void Open()
{
EditorWindow.DockManager.SetDockState( DockName, true );
EditorWindow.DockManager.RaiseDock( DockName );
}
public ArchLayerTree Projected => projected;
[EditorEvent.Frame]
void OnFrame()
{
var tool = ArchTool.Active;
var plan = tool?.Plan;
var picked = tool?.Picked?.Item;
var drafts = tool?.Drafts.Count( draft => draft.Standing ) ?? 0;
var assemblies = plan?.Assemblies.Count ?? 0;
// The geometry column is a static preference, so it can be turned on from outside this panel - a viewport
// face pick does exactly that - and the rows only exist because a rebuild put them there.
var planChanged = (tool is not null) != trackedActive
|| ArchTool.ShowGeometry != trackedGeometry
|| !ReferenceEquals( plan, trackedPlan )
|| (tool?.Revision ?? 0) != trackedRevision
|| drafts != trackedDrafts
|| assemblies != trackedAssemblies;
trackedActive = tool is not null;
trackedGeometry = ArchTool.ShowGeometry;
trackedPlan = plan;
trackedRevision = tool?.Revision ?? 0;
trackedDrafts = drafts;
trackedAssemblies = assemblies;
if ( planChanged )
{
Rebuild();
}
// A flash fades over real time, so the stack has to keep repainting while one is alive.
if ( tool?.Flashing == true )
{
stack.Update();
}
var inStack = pickedInStack;
pickedInStack = false;
SyncFaces( tool, inStack );
if ( ReferenceEquals( picked, trackedPicked ) )
{
return;
}
trackedPicked = picked;
if ( inStack )
{
UpdateBreadcrumb( projected.Find( picked ) );
return;
}
SyncSelection();
}
// The tool OWNS the pick and the stack only shows it. The stack may write it on the one frame the user
// clicked in the stack, and never otherwise: reading its selection back every frame instead made anything
// that cleared the stack behind this - a folded-away geometry column, an element pick landing the same
// frame - read as a deselection, and a face picked in the viewport was gone again a frame later.
void SyncFaces( ArchTool tool, bool inStack )
{
if ( tool is null )
{
return;
}
if ( inStack )
{
tool.SelectFaces( Wanted() );
return;
}
if ( Same( tool.DebugFaces, Wanted() ) )
{
return;
}
Reveal( tool );
}
// A picked piece or lump row means every face under it.
List<ArchFaceDetail> Wanted()
{
var picked = stack.SelectedItems.ToList();
return picked
.OfType<ArchFacePiece>()
.SelectMany( piece => piece.Faces )
.Concat( picked.OfType<ArchFaceGroup>().SelectMany( group => group.Every ) )
.Concat( picked.OfType<ArchFaceDetail>() )
.Distinct()
.ToList();
}
static bool Same( IReadOnlyList<ArchFaceDetail> left, IReadOnlyList<ArchFaceDetail> right )
{
return left.Count == right.Count && left.All( right.Contains );
}
// Opens the way down to the faces the viewport picked - each one's layer ancestors, its GEOMETRY row,
// its piece and whatever lump holds it - then makes the stack's selection EQUAL the tool's pick.
// Selecting only the last one was the bug behind "ctrl+click will not pick two": the next frame read
// the stack back and collapsed the pick to that single row. Only possible at all because the tool owns
// the probe, so the row's face and the picked face are the same object.
bool Reveal( ArchTool tool )
{
// Folded away, so there is no row to reveal it on and no business clearing a selection this is not the
// one holding - the layer the viewport picked is still sitting in it.
if ( !ArchTool.ShowGeometry )
{
return false;
}
if ( tool.DebugFaces.Count == 0 )
{
stack.UnselectAll();
return false;
}
foreach ( var face in tool.DebugFaces )
{
if ( projected.Find( face.LayerId ) is not { } node )
{
continue;
}
for ( var ancestor = node; ancestor is not null; ancestor = ancestor.Parent )
{
stack.Open( projected.StableKey( ancestor ) );
}
stack.Open( (face.LayerId, "geometry") );
foreach ( var piece in tool.BuiltFaces( face.LayerId ).Where( piece => piece.Faces.Contains( face ) ) )
{
stack.Open( piece );
foreach ( var group in Holding( piece.Groups, face ) )
{
stack.Open( group );
}
}
}
stack.SelectItems( tool.DebugFaces, false );
stack.ScrollTo( tool.DebugFaces[^1] );
return true;
}
static IEnumerable<ArchFaceGroup> Holding( IEnumerable<ArchFaceGroup> groups, ArchFaceDetail face )
{
foreach ( var group in groups.Where( group => group.Holds( face ) ) )
{
yield return group;
foreach ( var child in Holding( group.Children, face ) )
{
yield return child;
}
}
}
void BuildHeader()
{
header.Clear( true );
header.Add( Tool( "add", "Add a layer inside the insertion target", AddMenu ) );
header.Add( Tool( "create_new_folder", "Group the selected layer - a group is the scope an operation inside it may reach", GroupPicked ) );
header.Add( Tool( "folder_off", "Dissolve the selected group; its members keep their own homes", UngroupPicked ) );
header.Add( Tool( "visibility", "Show every layer again", () => ArchTool.Active?.ShowEveryLayer() ) );
var isolate = new IconButton( "filter_center_focus" )
{
ToolTip = "Fold away the groups the current edit is not happening inside",
IconSize = 15,
FixedSize = 22,
IsToggle = true,
IsActive = ArchTool.IsolateGroups
};
isolate.OnToggled = value =>
{
ArchTool.IsolateGroups = value;
stack.Update();
};
header.Add( isolate );
// Classic Material Icons only - the editor's font has no Material Symbols glyphs, and a missing
// one renders as an empty box rather than failing.
var geometry = new IconButton( "widgets" )
{
ToolTip = "Show what each layer BUILT - its pieces and their faces, for picking one and copying its debug info",
IconSize = 15,
FixedSize = 22,
IsToggle = true,
IsActive = ArchTool.ShowGeometry
};
geometry.OnToggled = value =>
{
ArchTool.ShowGeometry = value;
if ( !value )
{
ArchTool.Active?.SelectFaces( null );
}
Rebuild();
};
header.Add( geometry );
header.AddStretchCell();
}
static IconButton Tool( string icon, string tooltip, Action clicked )
{
var button = new IconButton( icon ) { ToolTip = tooltip, IconSize = 15, FixedSize = 22 };
button.OnClick = () => clicked();
return button;
}
// The projection is metadata-only, so a full rebuild on every commit is cheap; open and selected
// state survives because every row's Value is a stable identity, not the rebuilt node.
void Rebuild()
{
projected = ArchTool.Active?.LayerTree ?? new ArchLayerTree();
built = ArchTool.Active is { } tool ? tool.BuiltMeshes.Keys.ToHashSet() : new HashSet<int>();
// The selection holds row VALUES, and a rebuild mints new face objects - left alone, the stack
// stays selected on faces no row shows any more and the viewport keeps highlighting them.
stack.UnselectAll();
stack.Clear();
foreach ( var group in projected.Domains )
{
var domain = new ArchDomainRow( group.Name, group.Domain );
stack.AddItem( domain );
foreach ( var child in group.Children )
{
domain.AddItem( RowFor( child ) );
}
foreach ( var draft in RootDrafts( group.Domain ) )
{
domain.AddItem( new ArchDraftRow( draft ) );
}
}
trackedPicked = null;
}
ArchLayerRow RowFor( ArchLayerNode node )
{
var row = new ArchLayerRow( node, projected.StableKey( node ) );
foreach ( var child in node.Children )
{
row.AddItem( RowFor( child ) );
}
// A draft names the parent it will land in before any plan edit exists to show it.
foreach ( var draft in DraftsUnder( node.Ref?.ItemId ?? 0 ) )
{
row.AddItem( new ArchDraftRow( draft ) );
}
// What this layer actually BUILT, nested the way the generator named it - the fascia under the gutters
// under the roof - so a fitting is reached by opening the thing it hangs on rather than hunted for.
foreach ( var piece in ArchTool.Active?.PiecesOf( node.Ref?.ItemId ?? 0 ) ?? Array.Empty<ArchBuiltPiece>() )
{
row.AddItem( new ArchPieceRow( piece ) );
}
if ( ArchTool.ShowGeometry && node.Ref is { } layer && built.Contains( layer.ItemId ) )
{
row.AddItem( new ArchGeometryHeadRow( layer.ItemId ) );
}
return row;
}
static IEnumerable<ArchDraft> DraftsUnder( int parentId )
{
return ArchTool.Active?.Drafts
.Where( draft => draft.Active && draft.Standing && (draft.Parent?.ItemId ?? 0) == parentId )
?? Enumerable.Empty<ArchDraft>();
}
static IEnumerable<ArchDraft> RootDrafts( ArchLayerDomain domain )
{
var kinds = ArchKinds.Load();
return ArchTool.Active?.Drafts
.Where( draft => draft.Active && draft.Standing
&& (draft.Parent is null || draft.Parent.Value.ItemId == 0)
&& kinds.Domain( draft.Kind ) == domain )
?? Enumerable.Empty<ArchDraft>();
}
void OnItemClicked( object obj )
{
pickedInStack = true;
// Clicking anything that is not geometry drops the face pick outright, and drops the rows holding
// it with it. Left to the tree's own bookkeeping, values whose rows are long gone stayed in the
// selection and the highlight never let go.
if ( obj is not (ArchGeometryFaceRow or ArchGeometryGroupRow or ArchGeometryPieceRow) )
{
stack.UnselectAll();
ArchTool.Active?.SelectFaces( null );
}
if ( obj is ArchLayerRow row )
{
ApplySelection( row.Node );
}
}
void ApplySelection( ArchLayerNode node )
{
if ( ArchTool.Active is not { } tool )
{
return;
}
// An anchor row names a link to somewhere else; clicking it goes there.
if ( node.Payload is ArchLayerReference reference )
{
if ( projected.Find( reference.TargetId ) is { } target )
{
tool.SelectLayer( target );
}
return;
}
// Story headers step the view; only payload rows become selections.
if ( node.Payload is null )
{
if ( node.Kind == ArchKind.Story && node.Building is { } storyBuilding )
{
tool.Level = node.Floor;
tool.ActiveBuildingId = storyBuilding.Id;
}
return;
}
tool.SelectLayer( node );
}
// Only for a pick made somewhere else - the viewport, or a tool. A pick the stack itself made is
// already on screen the way the user clicked it.
void SyncSelection()
{
var node = projected.Find( ArchTool.Active?.Picked?.Item );
UpdateBreadcrumb( node );
// A face pick owns the rows while it lives, so the element only says where it is. Stamping its own row
// over them dropped the faces, and the empty stack then read back as a deselection.
if ( ArchTool.Active?.DebugFaces.Count > 0 )
{
return;
}
stack.UnselectAll();
if ( node is not null )
{
stack.SelectItem( projected.StableKey( node ) );
}
}
void UpdateBreadcrumb( ArchLayerNode selected )
{
var tool = ArchTool.Active;
if ( tool is null )
{
breadcrumb.Text = "No Architecture tool active";
return;
}
var target = tool.InsertionTarget is { } insertion && projected.Find( insertion.ItemId ) is { } targetNode
? targetNode.DisplayName
: "—";
breadcrumb.Text = selected is null
? $"Nothing picked · into {target}"
: $"{projected.Breadcrumb( selected )} · into {target}";
}
void AddMenu()
{
if ( ArchTool.Active is not { } tool )
{
return;
}
var kinds = ArchKinds.Load();
var parent = tool.InsertionTarget ?? tool.SelectedLayer;
var menu = new ContextMenu( this );
foreach ( var kind in Offered( kinds, parent ) )
{
var captured = kind;
menu.AddOption( kinds.Label( captured ), kinds.Glyph( captured ),
() => tool.BeginChild( parent ?? default, captured ) );
}
menu.OpenAtCursor();
}
// The root kinds are asked of the table, never named here, so a kind a module brings roots without core knowing it.
static IReadOnlyList<ArchKind> Offered( ArchKinds kinds, ArchLayerRef? parent )
{
var offered = parent is { } layer
? ArchLayerRules.ValidChildren( layer.Kind )
: kinds.RootKinds();
return offered.Where( kind => kinds.Subtool( kind ).Length > 0 ).ToList();
}
// Every selected row, not just the picked one - grouping several layers is what ctrl-clicking them
// is for, and taking only the last one made the gesture look broken.
void GroupPicked()
{
if ( ArchTool.Active is not { } tool )
{
return;
}
var members = stack.SelectedItems
.Select( item => projected.Find( item ) )
.Where( node => node?.Ref is not null )
.Select( node => node.Ref.Value.ItemId )
.ToList();
if ( members.Count == 0 && tool.SelectedLayer is { } layer )
{
members.Add( layer.ItemId );
}
tool.GroupLayers( members );
}
void UngroupPicked()
{
if ( ArchTool.Active is not { } tool )
{
return;
}
var group = tool.Picked?.Item as ArchSiteAssembly
?? ArchLayerGroups.Holding( tool.Plan, tool.SelectedLayer?.ItemId ?? 0 );
tool.UngroupLayer( group );
}
}
// The stack itself. Rows carry their own controls, so a press has to say whether it landed on the
// eye, the lock or the row before the tree decides it was a selection.
sealed class ArchLayerStack : TreeView
{
public const float ControlWidth = 22f;
public ArchLayerStack( Widget parent ) : base( parent )
{
// A z-fight is between TWO faces, so more than one row has to be pickable at a time.
MultiSelect = true;
}
protected override bool OnItemPressed( VirtualWidget item, MouseEvent e )
{
if ( item.Object is ArchPieceRow piece && ArchTool.Active is { } owner )
{
return Pressed( piece, owner, item, e );
}
if ( item.Object is not ArchLayerRow row || row.Node.Ref is not { } layer || ArchTool.Active is not { } tool )
{
return base.OnItemPressed( item, e );
}
var local = e.LocalPosition.x - item.Rect.Left;
var fromRight = item.Rect.Width - local;
if ( fromRight <= ControlWidth )
{
tool.ToggleLayerLocked( layer );
return false;
}
if ( fromRight <= ControlWidth * 2f )
{
tool.ToggleLayerHidden( layer.ItemId );
Update();
return false;
}
// The badge opens the same menu the right-click does - it is a way IN to the choice, not a fourth thing that
// silently cycles through four modes.
if ( fromRight <= ControlWidth * 3f && row.Node.Payload is IArchCollides )
{
var menu = new ContextMenu( this );
row.Collisions( menu, tool );
menu.OpenAtCursor();
return false;
}
return base.OnItemPressed( item, e );
}
// A piece has no lock, so column 0 is left blank and the other two line up with the layer rows above it.
bool Pressed( ArchPieceRow row, ArchTool tool, VirtualWidget item, MouseEvent e )
{
var fromRight = item.Rect.Width - (e.LocalPosition.x - item.Rect.Left);
if ( fromRight <= ControlWidth )
{
return false;
}
if ( fromRight <= ControlWidth * 2f )
{
tool.TogglePieceHidden( row.Built.Owner, row.Built.Piece );
Update();
return false;
}
if ( fromRight <= ControlWidth * 3f )
{
var menu = new ContextMenu( this );
row.Collisions( menu, tool );
menu.OpenAtCursor();
return false;
}
return base.OnItemPressed( item, e );
}
}
// One row per authored layer. Value is the stable identity - the payload itself for authored rows,
// a synthetic story key for virtual ones - so the tree keeps open and selected state across rebuilds.
sealed class ArchLayerRow : TreeNode
{
public ArchLayerNode Node { get; }
public ArchLayerRow( ArchLayerNode node, object key ) : base( key )
{
Node = node;
Height = 22;
}
bool IsGroup => Node.Payload is ArchSiteAssembly;
int ItemId => Node.Ref?.ItemId ?? 0;
public override void OnPaint( VirtualWidget item )
{
var tool = ArchTool.Active;
var kinds = ArchKinds.Load();
var rect = item.Rect;
PaintSelection( item );
// A folder reads as a band so the scope it draws around its members is visible at a glance.
if ( IsGroup )
{
Paint.ClearPen();
Paint.SetBrush( Theme.Blue.WithAlpha( 0.12f ) );
Paint.DrawRect( rect, 2f );
}
// The flash: what the last edit reached, fading out, so an edit never lands invisibly.
var flash = tool?.Flash( ItemId ) ?? 0f;
if ( flash > 0f )
{
Paint.ClearPen();
Paint.SetBrush( Theme.Green.WithAlpha( 0.45f * flash ) );
Paint.DrawRect( rect, 2f );
}
// The insertion target wears a persistent left-edge accent - the tree's answer to "where
// will the next thing I place go?".
if ( tool?.InsertionTarget is { } target && target.ItemId == ItemId )
{
Paint.ClearPen();
Paint.SetBrush( Theme.Primary );
Paint.DrawRect( new Rect( rect.Left, rect.Top, 3f, rect.Height ), 0 );
}
var muted = tool?.Muted( Node ) == true;
var hidden = tool?.LayerHidden( ItemId ) == true;
var alpha = muted || hidden ? 0.28f : Node.Enabled ? 1f : 0.45f;
Paint.SetPen( Theme.Text.WithAlpha( alpha ) );
Paint.DrawIcon( rect.Shrink( 4, 0, 0, 0 ), kinds.Glyph( Node.Kind ), 15, TextFlag.LeftCenter );
var controls = ArchLayerStack.ControlWidth * 4f;
var body = rect.Shrink( 24, 0, controls + 4f, 0 );
Paint.DrawText( body, Node.DisplayName, TextFlag.LeftCenter );
Paint.SetPen( Theme.Text.WithAlpha( 0.3f * alpha ) );
Paint.DrawText( body, Node.Stage.ToString().ToLowerInvariant(), TextFlag.RightCenter );
Collision( rect );
Palette( rect, alpha );
Paint.SetPen( Theme.Text.WithAlpha( hidden ? 0.75f : 0.3f ) );
Paint.DrawIcon( Control( rect, 1 ), hidden ? "visibility_off" : "visibility", 14, TextFlag.Center );
Paint.SetPen( Node.Locked ? Theme.Yellow : Theme.Text.WithAlpha( 0.3f ) );
Paint.DrawIcon( Control( rect, 0 ), Node.Locked ? "lock" : "lock_open", 14, TextFlag.Center );
}
void Palette( Rect rect, float alpha )
{
if ( !ArchLayerPalettePicker.HasOverride( Node ) )
{
return;
}
Paint.SetPen( Theme.Primary.WithAlpha( alpha ) );
Paint.DrawIcon( Control( rect, 3 ), "palette", 14, TextFlag.Center );
}
// Dim while the layer is following whatever is above it, lit once it has been given an answer of its own - so the
// column reads at a glance as "these few are different" rather than as one more icon on every row.
void Collision( Rect rect )
{
if ( Node.Payload is not IArchCollides )
{
return;
}
var mode = ArchCollision.Showing( Node, ArchTool.Active?.Kit, out var own );
Paint.SetPen( !own
? Theme.Text.WithAlpha( 0.3f )
: mode == ArchCollisionMode.Complex ? Theme.Yellow : Theme.Blue );
Paint.DrawIcon( Control( rect, 2 ), ArchCollision.Glyph( mode ), 14, TextFlag.Center );
}
static Rect Control( Rect row, int fromRight )
{
var width = ArchLayerStack.ControlWidth;
return new Rect( row.Right - width * (fromRight + 1), row.Top, width, row.Height );
}
public override string GetTooltip()
{
var problems = ArchTool.Active?.LayerTree.Problems( Node );
return problems is { Count: > 0 }
? problems[0]
: $"{ArchKindsAsked.Label( Node.Kind )} · {Node.Stage} · id {ItemId}"
+ (ArchLayerPalettePicker.HasOverride( Node ) ? " · palette override" : "");
}
public override void OnActivated()
{
if ( ArchTool.Active is { } tool && Node.Payload is not null )
{
tool.SelectLayer( Node );
tool.Frame();
}
}
public override bool OnDragStart()
{
if ( Node.Payload is null )
{
return false;
}
var drag = new Drag( TreeView );
drag.Data.Object = Node;
drag.Execute();
return true;
}
// Dropping ON a row nests into it; dropping on its top or bottom edge reorders beside it. Both
// go through the plan, never through the projection, so the tree cannot show a lie.
public override DropAction OnDragDrop( ItemDragEvent e )
{
if ( e.Data.Object is not ArchLayerNode moving || moving.Ref is not { } layer || ArchTool.Active is not { } tool )
{
return DropAction.Ignore;
}
if ( ReferenceEquals( moving, Node ) || Descends( Node, moving ) )
{
return DropAction.Ignore;
}
var beside = e.DropEdge.HasFlag( ItemEdge.Top ) || e.DropEdge.HasFlag( ItemEdge.Bottom );
if ( beside )
{
if ( !e.IsDrop )
{
return DropAction.Move;
}
if ( tool.LayerTree.Reorder( tool.Plan, layer, ItemId, e.DropEdge.HasFlag( ItemEdge.Bottom ) ) )
{
tool.Affect( layer.ItemId );
tool.Commit( $"Reorder {moving.Name}" );
}
return DropAction.Move;
}
if ( !IsGroup && !ArchLayerRules.CanParent( Node.Kind, layer.Kind ).Allowed )
{
return DropAction.Ignore;
}
if ( e.IsDrop && tool.LayerTree.Reparent( tool.Plan, layer, ItemId ) )
{
tool.Affect( layer.ItemId );
tool.Commit( $"Move {moving.Name} into {Node.Name}" );
}
return DropAction.Move;
}
static bool Descends( ArchLayerNode node, ArchLayerNode ancestor )
{
for ( var current = node; current is not null; current = current.Parent )
{
if ( ReferenceEquals( current, ancestor ) )
{
return true;
}
}
return false;
}
// What this layer collides as, set where the layer lives. The one option that is not a mode is "follow the kit",
// which is what every layer is until somebody says otherwise.
public void Collisions( ContextMenu menu, ArchTool tool )
{
if ( Node.Payload is not IArchCollides host )
{
return;
}
var showing = ArchCollision.Showing( Node, tool.Kit, out _ );
var collisions = menu.AddMenu( "Collisions", ArchCollision.Glyph( showing ) );
// Named for what it actually follows, so clearing an override says where the answer will come from instead.
var following = ArchCollision.Above( Node ) is { } parent
? $"Follow the layer above — {ArchCollision.Describe( parent )}"
: $"Follow the kit — {ArchCollision.Describe( tool.Kit?.Physics ?? ArchCollisionMode.Solids )}";
collisions.AddOption( following, "settings", () => Collide( tool, host, null ) );
collisions.AddSeparator();
foreach ( var mode in new[] { ArchCollisionMode.Solids, ArchCollisionMode.Convex, ArchCollisionMode.Complex, ArchCollisionMode.None } )
{
var captured = mode;
collisions.AddOption( ArchCollision.Describe( captured ), ArchCollision.Glyph( captured ),
() => Collide( tool, host, captured ) );
}
}
// The whole SUBTREE is what this reaches, because everything under it that has no answer of its own was following
// this one - so the scope that gets dirtied is the group holding it, not the row that was clicked.
void Collide( ArchTool tool, IArchCollides host, ArchCollisionMode? mode )
{
host.Collision = mode;
tool.Affect( ArchLayerGroups.Holding( tool.Plan, ItemId )?.Id ?? ItemId );
tool.Commit( $"Collisions {Node.Name}" );
}
public override bool OnContextMenu()
{
if ( ArchTool.Active is not { } tool || Node.Payload is null || Node.Ref is not { } layer )
{
return false;
}
var menu = new ContextMenu( TreeView );
menu.AddOption( "Set as insertion target", "playlist_add", () => tool.InsertionTarget = layer );
menu.AddOption( Node.Enabled ? "Disable generation" : "Enable generation",
Node.Enabled ? "toggle_off" : "toggle_on", () => tool.ToggleLayerEnabled( layer ) );
menu.AddOption( Node.Locked ? "Unlock" : "Lock", Node.Locked ? "lock_open" : "lock",
() => tool.ToggleLayerLocked( layer ) );
Collisions( menu, tool );
if ( ArchLayerPalettePicker.CanOpen( tool, Node ) )
{
menu.AddOption( "Apply Palette", "palette", () => ArchLayerPalettePicker.Show( TreeView, tool, Node ) );
}
if ( Node.Payload is ArchCutPart { ResolvedDamage: ArchDamageKind.Masonry } cut )
{
menu.AddOption( "Bake to Target", "fit_screen", () =>
{
if ( !ArchDamage.BakeToTarget( tool.Plan, tool.EnsureKit(), cut ) )
{
Log.Warning( "Architecture: damage zone does not overlap a wall corner or pillar." );
return;
}
tool.Affect( layer.ItemId );
tool.Commit( "Bake Damage to Target" );
} );
}
menu.AddSeparator();
var kinds = ArchKinds.Load();
var add = menu.AddMenu( "Add child", "add" );
foreach ( var kind in ArchLayerRules.ValidChildren( Node.Kind ).Where( kind => kinds.Subtool( kind ).Length > 0 ) )
{
var captured = kind;
add.AddOption( kinds.Label( captured ), kinds.Glyph( captured ),
() => tool.BeginChild( layer, captured ) );
}
if ( IsGroup )
{
menu.AddOption( "Ungroup", "folder_off", () => tool.UngroupLayer( (ArchSiteAssembly)Node.Payload ) );
}
else
{
menu.AddOption( "Group", "create_new_folder", () => tool.GroupLayers( new[] { layer.ItemId } ) );
if ( ArchLayerGroups.Holding( tool.Plan, layer.ItemId ) is not null )
{
menu.AddOption( "Take out of its group", "output", () =>
{
ArchLayerGroups.Leave( tool.Plan, layer.ItemId );
tool.Affect( layer.ItemId );
tool.Commit( $"Take {Node.Name} out of its group" );
} );
}
}
menu.AddSeparator();
menu.AddOption( "Frame", "filter_center_focus", () => { tool.SelectLayer( Node ); tool.Frame(); } );
menu.AddOption( "Delete", "delete", () => { tool.SelectLayer( Node ); tool.DeleteSelected(); } );
menu.OpenAtCursor();
return true;
}
}
// A placement in flight: cyan, never in the plan until Finish.
sealed class ArchDraftRow : TreeNode
{
public ArchDraft Draft { get; }
public ArchDraftRow( ArchDraft draft ) : base( draft )
{
Draft = draft;
Height = 22;
}
public override void OnPaint( VirtualWidget item )
{
var kinds = ArchKinds.Load();
Paint.SetPen( Color.Cyan );
Paint.DrawIcon( item.Rect.Shrink( 4, 0, 0, 0 ), kinds.Glyph( Draft.Kind ), 15, TextFlag.LeftCenter );
Paint.DrawText( item.Rect.Shrink( 24, 0, 0, 0 ), $"{Draft.Name} — placing", TextFlag.LeftCenter );
}
}
// The domain head. Dropping a layer here takes it out of whatever group was holding it.
sealed class ArchDomainRow : TreeNode
{
readonly string label;
readonly string glyph;
public ArchDomainRow( string label, ArchLayerDomain domain ) : base( domain )
{
this.label = label;
glyph = domain switch
{
ArchLayerDomain.Buildings => "domain",
ArchLayerDomain.Connections => "link",
_ => "route"
};
Height = 24;
}
public override void OnPaint( VirtualWidget item )
{
Paint.SetPen( Theme.Text.WithAlpha( 0.85f ) );
Paint.DrawIcon( item.Rect.Shrink( 4, 0, 0, 0 ), glyph, 15, TextFlag.LeftCenter );
Paint.DrawText( item.Rect.Shrink( 24, 0, 0, 0 ), label.ToUpperInvariant(), TextFlag.LeftCenter );
}
public override DropAction OnDragDrop( ItemDragEvent e )
{
if ( e.Data.Object is not ArchLayerNode moving || moving.Ref is not { } layer || ArchTool.Active is not { } tool )
{
return DropAction.Ignore;
}
if ( ArchLayerGroups.Holding( tool.Plan, layer.ItemId ) is null )
{
return DropAction.Ignore;
}
if ( e.IsDrop )
{
ArchLayerGroups.Leave( tool.Plan, layer.ItemId );
tool.Affect( layer.ItemId );
tool.Commit( $"Take {moving.Name} out of its group" );
}
return DropAction.Move;
}
}