Editor/Tool/Subtools/ArchSelectSubtool.cs

Editor subtool for the architecture tool, implements selection behavior. Handles clicking and free-clicking to pick authored items or built geometry faces, cycles through stacked hits, toggles additive selection, drives UI sidebar options for the current selection or faces, and commits edits made with gizmos.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

[Title( "Select" ), Icon( "ads_click" ), Group( "01" )]
public sealed class ArchSelectSubtool( ArchTool owner ) : ArchSubtool( owner )
{
	bool editing;

	// Elements picks the authored thing; Geometry picks the FACE under the cursor, which is the only way
	// to point at half of a z-fight - a plan part cannot name one side of a shared plane.
	bool geometry;

	// Everything the last click's ray crossed, nearest first. A repeat click walks down it, because two
	// faces sharing a plane are at the SAME distance and no ray can prefer one of them.
	IReadOnlyList<ArchFaceDetail> faces = Array.Empty<ArchFaceDetail>();
	int faceIndex;

	// A latch as well as the modifier: the scene view has its own claim on ctrl+click, so a mode that
	// can only add while a key is held is a mode that sometimes cannot add at all.
	bool adding;

	// The last click's ranked stack: a repeat click on the same spot cycles it, Alt+click opens it.
	IReadOnlyList<ArchHit> stack = Array.Empty<ArchHit>();
	int stackIndex;
	bool stackOpen;
	Vector2 lastPoint = new( float.NaN, float.NaN );

	protected override bool UsesDrag => false;

	public override bool Placing => false;

	public override ArchViewAxis[] Works => new[] { ArchViewAxis.Top, ArchViewAxis.Front, ArchViewAxis.Side };

	protected override string Title() => "Selection";

	protected override string Advice()
	{
		if ( geometry )
		{
			return Owner.DebugFaces.Count == 1
				? $"{Owner.DebugFaces[0].Label} — the stack has opened to it; Copy Debug Info hands it over."
				: "Click a surface to pick the FACE under the cursor. The Plan Layers stack opens to it, and Copy Debug Info says what it collides with.";
		}

		return Owner.Picked?.Describe() ?? "Click an authored part, or Alt+click to list every part under the cursor. Repeat clicks cycle.";
	}

	// Commits only on release - Commit per frame would stack an undo per pixel. In the shared slot, so the
	// widgets stand whether the mode under them is reading elements or reading faces: a selection made in the
	// Plan Layers stack has to be draggable however it was made, and Geometry mode took the whole frame before
	// the hover path this used to live in was ever reached.
	protected override bool Adjusting()
	{
		if ( Owner.Picked is not { } picked )
		{
			editing = false;

			return false;
		}

		using ( ArchGhost.Begin() )
		{
			if ( ArchHandles.Draw( Owner, picked ) )
			{
				editing = true;
				Owner.Preview();
			}

			// An edit is a placement, so while a handle is held it previews like one.
			if ( editing )
			{
				ArchEditGhost.Draw( Owner, picked );
			}
		}

		if ( !editing )
		{
			return ArchShapeHandles.HandlePressed;
		}

		if ( !Gizmo.WasLeftMouseReleased )
		{
			return true;
		}

		editing = false;

		// Handle drag never commits till release, and the shaft must follow its flight.
		if ( picked.Item is ArchStairPart stair )
		{
			ArchPiercers.Pierce( Owner.Plan, picked.Building, picked.Room, stair, Owner.Kit );
		}

		Owner.Commit( $"Edit {picked.Describe()}" );

		return true;
	}

	protected override void OnClick( Vector2 point )
	{
		// A press the gizmo took is a handle grab, not a new pick.
		if ( Gizmo.Pressed.Any || editing )
		{
			return;
		}


		// Elevations pick the section; the plan asks the whole authored stack, not one winner.
		if ( Owner.InElevation )
		{
			stack = Array.Empty<ArchHit>();
			stackOpen = false;

			Owner.Select( PickInSection() );

			return;
		}

		stack = ArchPick.HitsAt( Owner.LayerTree, Owner.Kit, Owner.Level, point );
		stackOpen = global::Editor.Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Alt );

		// A repeat click on the same spot cycles the stack; a fresh click takes its top.
		stackIndex = point == lastPoint && stack.Count > 0 ? (stackIndex + 1) % stack.Count : 0;
		lastPoint = point;

		PickFromStack();
	}

	protected override bool TakesFreeClick => geometry;

	// Against the built faces themselves, not a trace: generated pieces carry no collider, and a plane
	// the ray crosses is only a hit if it crosses INSIDE the polygon. Ctrl adds to the pick and clicking
	// a picked face again drops it, so both halves of a fight can be handed over together.
	protected override void OnFreeClick()
	{
		var hits = Owner.FacesUnder( Gizmo.CurrentRay );
		var adds = Adding;

		if ( hits.Count == 0 )
		{
			faces = Array.Empty<ArchFaceDetail>();

			if ( !adds )
			{
				Owner.SelectFaces( null );
			}

			Refresh();

			return;
		}

		var repeat = faces.Count == hits.Count && faces.All( hits.Contains );

		faces = hits;

		// Adding walks INTO the stack: the nearest face not already picked, so a second ctrl-click on one
		// spot reaches the surface behind instead of un-picking what the first one took. That cycling is
		// what made a third face impossible - every repeat click toggled a picked face straight back off.
		if ( adds )
		{
			var next = hits.FirstOrDefault( face => !Owner.DebugFaces.Contains( face ) );

			faceIndex = hits.IndexOf( next ?? hits[0] );

			Take( next ?? hits[0], true );

			return;
		}

		faceIndex = repeat ? (faceIndex + 1) % hits.Count : 0;

		Take( hits[faceIndex], false );
	}

	// Read off the application, never off Gizmo: Gizmo.Active only exists inside a gizmo scope, so a
	// sidebar button asking it threw a null reference and took the whole click with it. This is the same
	// source the scene view feeds its own modifiers from.
	static bool Adds => global::Editor.Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Ctrl )
		|| global::Editor.Application.KeyboardModifiers.HasFlag( KeyboardModifiers.Shift );

	bool Adding => adding || Adds;

	void Take( ArchFaceDetail face, bool adding )
	{
		var picked = Owner.DebugFaces.ToList();

		if ( !adding )
		{
			picked.Clear();
			picked.Add( face );
		}
		else if ( !picked.Remove( face ) )
		{
			picked.Add( face );
		}

		Owner.SelectFaces( picked );

		// Picking a face IS asking to see what was built, and the stack cannot open to it with the geometry
		// column folded away.
		ArchTool.ShowGeometry = true;

		ArchLayersDockPanel.Open();
		Refresh();
	}

	void PickFromStack()
	{
		if ( stack.Count == 0 )
		{
			Owner.Select( null );

			return;
		}

		var hit = stack[Math.Min( stackIndex, stack.Count - 1 )];
		var node = Owner.LayerTree.Find( hit.Layer.ItemId );

		if ( node is null )
		{
			Owner.Select( null );

			return;
		}

		Owner.Select( new ArchSelection { Item = node.Payload, Room = node.Room, Building = node.Building } );
	}

	// An edge-on footprint is never under the cursor, so elevations pick the section.
	ArchSelection PickInSection()
	{
		if ( Owner.EditedBuilding() is not { } building || !Owner.Cursor( out var cursor ) )
		{
			return null;
		}

		var cut = Owner.Section( building );

		if ( cut.At( cut.Plane.Across( cursor.Plan ), cursor.Height ) is not { } piece )
		{
			return null;
		}

		var room = building.Rooms.FirstOrDefault( candidate => candidate.Floor == piece.Floor
			&& (ReferenceEquals( candidate, piece.Item )
				|| candidate.Walls.Any( wall => ReferenceEquals( wall, piece.Item ) || wall.Openings.Any( opening => ReferenceEquals( opening, piece.Item ) ) )) );

		return new ArchSelection { Item = piece.Item, Room = room, Building = building };
	}

	// Edited through its own subtool's groups; changes apply themselves - it debounces.
	protected override void BuildOptions( ToolSidebarWidget panel )
	{
		ArchSidebarSection.Show( panel, Scope( "pick" ), "Pick", group =>
		{
			using ( var grid = ArchIconGrid.In( group ) )
			{
				grid.Pick( "Elements — the authored parts: buildings, rooms, walls, the things you drew", "select_element", "ads_click",
					!geometry, () => { geometry = false; Owner.SelectFaces( null ); Refresh(); } );

				grid.Pick( "Geometry — the built FACE under the cursor, for debugging a z-fight or a buried fitting", "select_face", "crop_square",
					geometry, () => { geometry = true; Refresh(); } );
			}

			if ( geometry )
			{
				group.Add( ArchPartUi.Check( "Add each click to the pick (or hold Ctrl / Shift)", adding,
					value => adding = value ) );
			}
		} );

		if ( geometry )
		{
			BuildFaceOptions( panel );

			return;
		}

		// The Alt+click stack names every hit; clicking a row takes that layer, however deep.
		if ( stackOpen && stack.Count > 0 )
		{
			var group = panel.AddGroup( "Objects under cursor" );

			for ( var index = 0; index < stack.Count; index++ )
			{
				var captured = index;
				var hit = stack[index];
				var node = Owner.LayerTree.Find( hit.Layer.ItemId );
				var name = node is null ? hit.Layer.Kind.ToString() : Owner.LayerTree.Breadcrumb( node );

				group.Add( new Button( $"{name} · {hit.Channel}" )
				{
					Clicked = () =>
					{
						stackIndex = captured;
						PickFromStack();
						Refresh();
					}
				} );
			}
		}

		if ( Owner.Picked is not { Item: not null } picked )
		{
			return;
		}

		// The shelf follows the selection, but only from a tool that can point its controls AT the selection.
		// Borrowed from one that cannot, every option on the panel tuned the seed for the next drag instead -
		// which is what "none of the edit options work" was.
		if ( Owner.LayerTree.Find( picked.Item ) is { } layer && Authoring( layer.Kind ) is { Adopts: true } authoring && authoring != this )
		{
			authoring.BuildAdopted( panel, picked, Refresh );

			return;
		}

		// Every option the part has, right here, applying as they change. Sending the author to the Layer
		// Inspector for them made two panels the answer to one question - and the one they were looking at
		// was the one that could not answer it.
		ArchPartUi.Sheet( panel, Owner, picked, Refresh );
	}

	void BuildFaceOptions( ToolSidebarWidget panel )
	{
		// Every face the ray went through, so the one behind is one row away rather than several clicks.
		if ( faces.Count > 0 )
		{
			var under = panel.AddGroup( "Under the cursor" );

			for ( var index = 0; index < faces.Count; index++ )
			{
				var captured = index;
				var face = faces[index];
				var picked = Owner.DebugFaces.Contains( face );

				under.Add( new Button( $"{(picked ? "✓ " : "")}{face.Piece.Split( '/' )[^1]} · {face.Role} · {face.Plane}" )
				{
					Clicked = () =>
					{
						faceIndex = captured;
						Take( face, Adding );
					}
				} );
			}
		}

		if ( Owner.DebugFaces.Count == 0 )
		{
			panel.AddGroup( "Face" ).Add( Wrapped( "Nothing picked. Click any built surface — ceilings and soffits included. Ctrl+click adds, clicking again drops." ) );

			return;
		}

		var group = panel.AddGroup( $"{Owner.DebugFaces.Count} picked" );

		foreach ( var face in Owner.DebugFaces.Take( 6 ) )
		{
			group.Add( Wrapped( $"{face.Piece.Split( '/' )[^1]} · {face.Label}" ) );
		}

		var fought = Owner.DebugFaces
			.SelectMany( Owner.Fighting )
			.Distinct()
			.Where( face => !Owner.DebugFaces.Contains( face ) )
			.ToList();

		if ( fought.Count > 0 )
		{
			group.Add( new Button( $"Add the {fought.Count} face(s) it z-fights", "layers" )
			{
				Clicked = () =>
				{
					Owner.SelectFaces( Owner.DebugFaces.Concat( fought ).ToList() );
					Refresh();
				}
			} );
		}

		group.Add( new Button.Primary( "Copy Briefing", "smart_toy" ) { Clicked = () => Log.Info( Owner.CopyFaceDebug( true ) ) } );
		group.Add( new Button( "Copy Debug Info Plain", "content_copy" ) { Clicked = () => Log.Info( Owner.CopyFaceDebug() ) } );
	}
}