s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
link Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=trashpandagames.hierarchyextension&take=20
Showing code results for query:
*
(13 total matches found)
Editor
library
using System.Collections.Generic;
using System.Linq;
namespace HierarchyExtension;
/// <summary>
/// Objects that can't be selected in the scene view. A click goes through them to whatever is behind, and a drag
/// selection leaves them out. They still select from the Hierarchy, so they can be inspected and edited as usual.
/// <para>
/// An object is made unselectable with the <see cref="Tag"/> tag, the way the stock Hide marks hidden objects with
/// "hidden". It saves with the scene, shows in the object's Tags, takes undo, and counts as an override on a prefab
/// instance. Tags pass down to children, so everything under an unselectable object is unselectable too.
/// </para>
/// <para>
/// Set it with the hand on each Hierarchy row (<see cref="SelectableColumn"/>), with <b>Scene > View > Toggle
/// Selectable</b> (Alt+L) for the selected objects, or with <b>Make All Selectable</b>. <see cref="ScenePicking"/>
/// makes the scene view honour it.
/// </para>
/// </summary>
public static class Unselectable
{
/// <summary>The tag that makes an object, and everything under it, unselectable in the scene view.</summary>
public const string Tag = "unselectable";
/// <summary>Whether <paramref name="go"/> can't be selected in the scene view, from its own tag or a parent's.</summary>
public static bool Has( GameObject go ) => go.IsValid() && go is not Scene && go.Tags.Has( Tag );
/// <summary>Whether <paramref name="go"/> has the tag itself, not only from a parent.</summary>
public static bool IsSet( GameObject go ) => go.IsValid() && go is not Scene && go.Tags.Has( Tag, false );
/// <summary>
/// The object whose tag makes <paramref name="go"/> unselectable: itself, or the nearest parent with the tag.
/// Null when it's selectable.
/// </summary>
public static GameObject Source( GameObject go )
{
for ( var o = go; o.IsValid() && o is not Scene; o = o.Parent )
{
if ( IsSet( o ) ) return o;
}
return null;
}
/// <summary>
/// Make <paramref name="objects"/> unselectable in the scene view, or selectable again, as one undo step. Objects
/// that are that way already are left alone. Making an object selectable only takes its own tag off, so one under
/// an unselectable parent stays unselectable; pass <see cref="Source"/> to free it.
/// </summary>
public static void Set( IEnumerable<GameObject> objects, bool unselectable )
{
var changing = objects.Where( x => x.IsValid() && x is not Scene && IsSet( x ) != unselectable ).Distinct().ToArray();
if ( changing.Length == 0 ) return;
var session = SessionOf( changing[0] );
if ( session is null ) return;
var name = unselectable ? "Make Unselectable" : "Make Selectable";
if ( changing.Length == 1 ) name = $"{name}: {changing[0].Name}";
using var sceneScope = session.Scene.Push();
// only the objects' own properties, which is where tags live: a tile layer's children and components can be big
using ( session.UndoScope( name ).WithGameObjectChanges( changing, GameObjectUndoFlags.Properties ).Push() )
{
foreach ( var go in changing )
{
if ( unselectable ) go.Tags.Add( Tag );
else go.Tags.Remove( Tag );
}
}
}
/// <summary>
/// Alt+L: make the selected objects unselectable in the scene view or, when they all are already, selectable again.
/// One that's unselectable through a parent frees that parent, and so everything under it.
/// </summary>
[Menu( "Editor", "Scene/View/Toggle Selectable", "do_not_touch" )]
[Shortcut( "editor.toggle-selectable", "Alt+L" )]
public static void ToggleSelected()
{
var session = SceneEditorSession.Active;
if ( session is null ) return;
var selected = session.Selection.OfType<GameObject>().Where( x => x.IsValid() && x is not Scene ).ToArray();
if ( selected.Length == 0 ) return;
if ( selected.Any( x => !Has( x ) ) ) Set( selected, true );
else Set( selected.Select( Source ), false );
}
/// <summary>Make everything in the open scene selectable in the scene view again.</summary>
[Menu( "Editor", "Scene/View/Make All Selectable", "touch_app" )]
public static void MakeAllSelectable()
{
var scene = SceneEditorSession.Active?.Scene;
if ( !scene.IsValid() ) return;
Set( scene.GetAllObjects( false ).Where( IsSet ), false );
}
/// <summary>Whether anything in <paramref name="scene"/> is unselectable. Cheap enough to ask on every click.</summary>
public static bool AnyIn( Scene scene )
{
if ( !scene.IsValid() ) return false;
foreach ( var go in scene.GetAllObjects( true ) )
{
if ( IsSet( go ) ) return true;
}
return false;
}
/// <summary>The editor session whose scene holds <paramref name="go"/>, the play session's included.</summary>
public static SceneEditorSession SessionOf( GameObject go ) => SessionOf( go?.Scene );
/// <summary>The editor session of <paramref name="scene"/>, the play session's included.</summary>
public static SceneEditorSession SessionOf( Scene scene )
{
if ( scene is null ) return null;
return SceneEditorSession.All.FirstOrDefault( x => x.Scene == scene );
}
}
Editor
library
global using Sandbox; global using Editor; global using System.Collections.Generic; global using System.Linq;
Editor
library
using System.Collections.Generic;
using System.Linq;
namespace HierarchyExtension;
/// <summary>
/// Outlines what's selected in the scene view: one box around each selected object and everything under it (its
/// sprites, models, colliders, particles and icons: <see cref="ScenePicking.VisibleBounds"/>), a thin line drawn over
/// everything in the editor's own selected colour. An object inside another selected one gets no box of its own; the
/// other's covers it. During a drag selection it outlines what the drag will select (<see cref="ScenePicking.DragResult"/>).
/// <para>
/// Why: the stock scene view only outlines an object's own sprite or model (<c>SpriteRenderer.DrawGizmos</c> asks
/// <c>Gizmo.IsSelected</c> in the sprite's own object; the viewport's <c>DrawSelection</c> takes the selected object's
/// own <c>ModelRenderer</c>s). A prefab, or a group, keeps its sprites on its children, and selecting it selects the
/// root, so a selected decoration, or Sera, showed nothing but the move handle (tools and engine source, 26.09.29).
/// </para>
/// </summary>
public static class SelectionOutline
{
/// <summary>Draws the outlines. Called from the scene view's gizmo pass (<see cref="ScenePicking"/>).</summary>
internal static void Draw( IEnumerable<object> selected )
{
var objects = selected.OfType<GameObject>().Where( x => x.IsValid() && x is not Scene ).ToHashSet();
var boxes = new List<BBox>();
foreach ( var go in objects )
{
if ( HasAncestorIn( go, objects ) ) continue;
// a point (an icon and nothing else) is shown by the icon itself
if ( ScenePicking.VisibleBounds( go ) is { } bounds && bounds.Size.Length > 0.01f ) boxes.Add( bounds );
}
if ( boxes.Count == 0 ) return;
// One colour and one thickness, so it's all one batch. A dark edge under the line looked better, but it has to be a
// thicker line, and a change of thickness starts another batch (Gizmo.Draw.CanReuseVertexObject). Batches drawn
// over everything have no order between them, so after a reselect the edge sometimes landed on top of the line.
using var scope = Gizmo.Scope( "hierarchyextension-selection" );
Gizmo.Transform = global::Transform.Zero;
Gizmo.Draw.IgnoreDepth = true;
Gizmo.Draw.Color = Gizmo.Colors.Selected;
Gizmo.Draw.LineThickness = 1.5f;
foreach ( var box in boxes ) Gizmo.Draw.LineBBox( box );
}
static bool HasAncestorIn( GameObject go, HashSet<GameObject> objects )
{
for ( var o = go.Parent; o.IsValid() && o is not Scene; o = o.Parent )
{
if ( objects.Contains( o ) ) return true;
}
return false;
}
}
Editor
library
using System.Collections.Generic;
using System.Linq;
namespace HierarchyExtension;
/// <summary>
/// Works out what a click in the scene view would select if the unselectable objects weren't there. It plays the click
/// again through a gizmo instance of its own, over the scene's own gizmos, with the unselectable objects held out:
/// the engine picks, so whatever it picks normally (render meshes, sprites, handles, a component's hitboxes, selection
/// bases) is picked the same way.
/// <para>
/// Why not stop the engine picking them in the first place: the scene view has no per-object switch. The only objects
/// it skips are ones tagged "hidden" or flagged Hidden, and both stop them drawing or listing too (engine source,
/// <c>GameObject.DrawGizmos</c> and <c>SceneViewportWidget.UpdateHovered</c>, 26.09.29). So the objects are flagged
/// Hidden only while the click is replayed here, and put back straight after, before anything draws or lists them.
/// </para>
/// </summary>
public static class ClickThrough
{
/// <summary>A change a click makes to the selection, in the order the click makes them.</summary>
public enum Change { Clear, Add, Remove }
/// <summary>
/// The selection changes a click with <paramref name="input"/> would make in <paramref name="scene"/> if the
/// unselectable objects weren't there, in order. Empty when it would hit nothing. The selection isn't touched:
/// the click reads <paramref name="selection"/>, to know what's selected, and only records what it would do.
/// Also used by <see cref="ScenePicking"/>; call it outside any gizmo drawing.
/// </summary>
public static List<(Change Change, object Target)> Run( Scene scene, Gizmo.SceneSettings settings, Gizmo.Inputs input, SelectionSystem selection )
{
return Run( scene, settings, input, selection, scene.GetAllObjects( true ).Where( Unselectable.IsSet ).ToArray() );
}
/// <summary>The same with <paramref name="heldOut"/>, and everything under them, held out.</summary>
public static List<(Change Change, object Target)> Run( Scene scene, Gizmo.SceneSettings settings, Gizmo.Inputs input, SelectionSystem selection, IReadOnlyList<GameObject> heldOut )
{
var recorder = new Recorder( selection );
if ( !scene.IsValid() ) return recorder.Changes;
// a GameObject flagged Hidden draws no gizmos, and nor do its children
var flagged = new List<GameObject>();
foreach ( var go in heldOut )
{
if ( !go.IsValid() || go.Flags.Contains( GameObjectFlags.Hidden ) ) continue;
go.Flags |= GameObjectFlags.Hidden;
flagged.Add( go );
}
try
{
using var instance = new Gizmo.Instance { Settings = settings, Selection = recorder };
// The gizmo state reads the mouse a frame late: a press is seen the frame after the button goes down, and a
// click the frame after it comes up, when the object hovered then is the one pressed.
input.IsHovered = true;
Frame( instance, scene, input, false, heldOut, draw: false );
Frame( instance, scene, input, true, heldOut );
Frame( instance, scene, input, false, heldOut );
Frame( instance, scene, input, false, heldOut );
}
finally
{
foreach ( var go in flagged )
{
if ( go.IsValid() ) go.Flags &= ~GameObjectFlags.Hidden;
}
}
return recorder.Changes;
}
/// <summary>
/// Plays the button going down at <paramref name="input"/>, nothing held out, and asks <paramref name="ask"/> on the
/// frame the press is seen, as the scene view's own code would see it (<c>Gizmo.Pressed</c>). For the bridge's
/// press probe; call it outside any gizmo drawing.
/// </summary>
public static T WhilePressed<T>( Scene scene, Gizmo.SceneSettings settings, Gizmo.Inputs input, System.Func<T> ask )
{
var none = System.Array.Empty<GameObject>();
using var instance = new Gizmo.Instance { Settings = settings, Selection = new Recorder( null ) };
input.IsHovered = true;
Frame( instance, scene, input, false, none, draw: false );
Frame( instance, scene, input, true, none );
input.LeftMouse = true;
instance.Input = input;
using var gizmos = instance.Push();
using var sceneScope = scene.Push();
return ask();
}
static void Frame( Gizmo.Instance instance, Scene scene, Gizmo.Inputs input, bool leftMouse, IReadOnlyList<GameObject> heldOut, bool draw = true )
{
input.LeftMouse = leftMouse;
instance.Input = input;
using var gizmos = instance.Push();
using var sceneScope = scene.Push();
if ( !draw ) return;
HoverRenderMeshes( scene, heldOut );
scene.EditorDraw();
}
/// <summary>
/// What the scene view does before the gizmos draw: trace the render meshes under the cursor and hover the object
/// hit. The stock one is private (<c>SceneViewportWidget.UpdateHovered</c>); this is it, holding out the objects.
/// </summary>
static void HoverRenderMeshes( Scene scene, IReadOnlyList<GameObject> heldOut )
{
if ( !Gizmo.Settings.Selection ) return;
var trace = scene.Trace.Ray( Gizmo.CurrentRay, Gizmo.RayDepth )
.UseRenderMeshes( true )
.UsePhysicsWorld( false )
.WithoutTags( "hidden" );
foreach ( var go in heldOut )
{
if ( go.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( go );
}
var tr = trace.Run();
if ( !tr.Hit || !tr.Component.IsValid() ) return;
using ( Gizmo.ObjectScope( tr.GameObject, tr.GameObject.WorldTransform ) )
{
Gizmo.Hitbox.DepthBias = 1;
Gizmo.Hitbox.TrySetHovered( tr.Distance );
}
}
/// <summary>
/// The replayed click's selection: it answers what's selected from the real one, and writes down the changes
/// instead of making them.
/// </summary>
sealed class Recorder : SelectionSystem
{
readonly SelectionSystem _selection;
public readonly List<(Change Change, object Target)> Changes = new();
public Recorder( SelectionSystem selection )
{
_selection = selection;
}
public override bool Contains( object obj ) => _selection?.Contains( obj ) ?? false;
public override bool Any() => _selection?.Any() ?? false;
public override void Clear() => Changes.Add( (Change.Clear, null) );
public override bool Add( object obj )
{
if ( obj is null ) return false;
Changes.Add( (Change.Add, obj) );
return true;
}
public override bool Remove( object obj )
{
if ( obj is null ) return false;
Changes.Add( (Change.Remove, obj) );
return true;
}
public override bool Set( object obj )
{
Clear();
return Add( obj );
}
}
}
UnitTest
library
using Sandbox;
[TestClass]
public partial class LibraryTests
{
[TestMethod]
public void SceneTest()
{
var scene = new Scene();
using ( scene.Push() )
{
var go = new GameObject();
Assert.AreEqual( 1, scene.Directory.GameObjectCount );
}
}
}
Editor
library
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
namespace HierarchyExtension;
/// <summary>
/// Makes the scene view honour <see cref="Unselectable"/>, and makes its drag selections take only what's fully inside.
/// <list type="bullet">
/// <item><description><b>Clicks</b>: each scene viewport's gizmos select through a <see cref="ClickFilter"/>. A click
/// that would select an unselectable object is held back. On the next editor frame <see cref="ClickThrough"/> plays it
/// again without the unselectable objects, and its selection is made instead, as one undo step. With nothing behind,
/// the click deselects, like a click on empty space.</description></item>
/// <item><description><b>Drags</b> that start on an unselectable object are drags on empty space: drag selections,
/// and the camera, take them as they would there (<see cref="LetGoOfDraggedPress"/>).</description></item>
/// <item><description><b>Drag selections</b> with the Object Select tool take only objects fully inside the box, never
/// unselectable ones, and a prefab only whole (<see cref="WatchDragSelection"/>).</description></item>
/// <item><description><b>Right-clicking</b> the scene view offers to make unselectable objects selectable again.</description></item>
/// </list>
/// <para>
/// The filter goes where the engine lets it: a viewport's <c>GizmoInstance.Selection</c> is a public setting, and only
/// the gizmos' clicks select through it (tools source, <c>SceneViewportWidget</c>, 26.09.29). The Hierarchy, the
/// inspector, tools and code select through the session's selection, so they can still pick anything.
/// </para>
/// </summary>
public sealed class ScenePicking : EditorEvent.ISceneView
{
/// <summary>How far the cursor goes before a drag selection starts (<c>EditorTool.UpdateBoxSelection</c>), in pixels.</summary>
const float DragDistance = 5;
static ScenePicking _listener;
static PendingClick _pending;
static DragWatch _drag;
/// <summary>Whether the press going on has been checked for landing on an unselectable object.</summary>
static bool _pressChecked;
/// <summary>
/// While a drag selection is going on in the scene view: what it will select when the mouse comes up. Null otherwise.
/// The Hierarchy shows this meanwhile (<see cref="HierarchySelection"/>).
/// </summary>
internal static HashSet<object> DragResult => _drag?.Result;
/// <summary>A click that was held back, with what the viewport's gizmos saw when it landed.</summary>
sealed record PendingClick( SceneViewportWidget Viewport, SelectionSystem Selection, Gizmo.Inputs Input );
/// <summary>A press begun in a viewport (known by its camera), and the drag selection it may become.</summary>
sealed class DragWatch
{
public Scene Scene;
public SceneCamera Camera;
/// <summary>Editor frames in a row the left button has been up while this drag still went on.</summary>
public int FramesUp;
/// <summary>What was selected when the press began.</summary>
public HashSet<object> Before;
/// <summary>The cursor's ray when the press began, and on the last frame the button was down, as the tool takes them.</summary>
public Ray Start, Last;
/// <summary>While it's a drag selection: what the selection will be when the mouse comes up. Null otherwise.</summary>
public HashSet<object> Result;
readonly Dictionary<GameObject, BBox?> _bounds = new();
/// <summary>
/// <see cref="VisibleBounds"/>, worked out once per drag, as the tool does its bounds: nothing moves while the
/// mouse is busy with a drag selection, and they're slow to get.
/// </summary>
public BBox? BoundsOf( GameObject go )
{
if ( !_bounds.TryGetValue( go, out var bounds ) ) _bounds[go] = bounds = VisibleBounds( go );
return bounds;
}
}
[EditorEvent.Frame]
static void Frame()
{
if ( _listener is null )
{
_listener = new ScenePicking();
EditorEvent.Register( _listener );
}
InstallFilters();
ResolvePendingClick();
// a drag whose viewport stopped drawing before its release was seen: end it once the button has been up a while
if ( _drag is { } drag )
{
drag.FramesUp = Editor.Application.MouseButtons.HasFlag( MouseButtons.Left ) ? 0 : drag.FramesUp + 1;
if ( drag.FramesUp > 3 ) Finish( drag );
}
}
/// <summary>A code reload keeps the listener; register it again in case the reload dropped it.</summary>
[EditorEvent.Hotload]
static void OnHotload()
{
if ( _listener is null ) return;
EditorEvent.Unregister( _listener );
EditorEvent.Register( _listener );
}
/// <summary>
/// Puts a filter on every viewport of the scene view in use, and on any it's lost: a viewport sets its selection
/// again when play starts or stops. Also lets through a clear that's still waiting for its add.
/// </summary>
static void InstallFilters()
{
var view = SceneViewWidget.Current;
if ( !view.IsValid() || view._viewports is null ) return;
foreach ( var viewport in view._viewports.Values )
{
var gizmos = viewport.IsValid() ? viewport.GizmoInstance : null;
if ( gizmos?.Selection is null ) continue;
if ( gizmos.Selection is ClickFilter filter )
{
filter.Flush();
continue;
}
// one from before a code reload that wasn't carried over: leave it rather than stack another on it
if ( gizmos.Selection.GetType().FullName == typeof( ClickFilter ).FullName ) continue;
gizmos.Selection = new ClickFilter( viewport, gizmos.Selection );
}
}
static void ResolvePendingClick()
{
var click = _pending;
if ( click is null ) return;
_pending = null;
var session = SceneEditorSession.All.FirstOrDefault( x => x.Selection == click.Selection );
var scene = session?.Scene;
if ( !scene.IsValid() || !click.Viewport.IsValid() ) return;
var changes = ClickThrough.Run( scene, click.Viewport.GizmoInstance.Settings, click.Input, click.Selection );
using var sceneScope = scene.Push();
using ( session.UndoScope( UndoName( changes ) ).Push() )
{
if ( changes.Count == 0 )
{
click.Selection.Clear();
return;
}
foreach ( var (change, target) in changes )
{
switch ( change )
{
case ClickThrough.Change.Clear: click.Selection.Clear(); break;
case ClickThrough.Change.Add: click.Selection.Add( target ); break;
case ClickThrough.Change.Remove: click.Selection.Remove( target ); break;
}
}
}
}
/// <summary>Named as the stock click's undo steps are: "Select {name}", or "Deselect all" for empty space.</summary>
static string UndoName( List<(ClickThrough.Change Change, object Target)> changes )
{
var last = changes.LastOrDefault( x => x.Change != ClickThrough.Change.Clear );
if ( last.Target is GameObject go ) return last.Change == ClickThrough.Change.Add ? $"Select {go.Name}" : $"Deselect {go.Name}";
return "Deselect all";
}
/// <summary>Runs in each viewport's frame after its tool, before the scene's gizmos.</summary>
void EditorEvent.ISceneView.DrawGizmos( Scene scene )
{
LetGoOfDraggedPress( scene );
WatchDragSelection( scene );
// what's selected, or during a drag selection what it will select
var selected = DragResult ?? (IEnumerable<object>)Unselectable.SessionOf( scene )?.Selection;
if ( selected is not null ) SelectionOutline.Draw( selected );
}
/// <summary>
/// A press on an unselectable object still counts as a press on something: the scene view hovers what's under the
/// cursor before anyone clicks, with no way to leave objects out. While anything is pressed, a drag doesn't start a
/// drag selection (<c>EditorTool.UpdateBoxSelection</c>) or move the camera (<c>SceneViewportWidget</c>), so a drag
/// that began on a backdrop did nothing. Once such a press has moved as far as a drag selection waits for, let go of
/// it (<c>Gizmo.Pressed.ClearPath</c>, as the engine does itself for a dragged object press): from the next frame the
/// drag is a drag on empty space, and a drag selection starts from where the press began. A press that doesn't move
/// stays a click, which the <see cref="ClickFilter"/> plays through to what's behind.
/// </summary>
static void LetGoOfDraggedPress( Scene scene )
{
if ( !Gizmo.HasMouseFocus ) return;
if ( Gizmo.WasLeftMousePressed ) _pressChecked = false;
if ( _pressChecked || !Gizmo.IsLeftMouseDown || !Gizmo.Pressed.Any ) return;
if ( Gizmo.Pressed.CursorDelta.Length < DragDistance ) return;
_pressChecked = true;
if ( PressedUnselectable( scene ).IsValid() ) Gizmo.Pressed.ClearPath();
}
/// <summary>
/// The unselectable object the press is on, or null. The gizmos name what's pressed by its path: an object's is
/// "object-{hash}" (<c>Gizmo.ObjectScope</c>), its handle's ".../Handle", and its components' hitboxes ".../Scope0",
/// ".../Scope1" and so on, in order (<c>GameObject.DrawGizmos</c>). <c>Gizmo.Pressed.This</c> compares the path it's
/// asked in, so each is tried. A hitbox a component draws inside a scope of its own isn't found.
/// </summary>
internal static GameObject PressedUnselectable( Scene scene ) => PressedObject( scene, Unselectable.Has );
/// <summary>The object the press is on, among those <paramref name="among"/> accepts, or null. See <see cref="PressedUnselectable"/>.</summary>
internal static GameObject PressedObject( Scene scene, System.Func<GameObject, bool> among )
{
using var scope = Gizmo.Scope( "hierarchyextension" );
foreach ( var go in scene.GetAllObjects( true ) )
{
if ( !among( go ) ) continue;
var path = $"object-{go.GetHashCode()}";
if ( IsPressed( path ) || IsPressed( $"{path}/Handle" ) ) return go;
for ( var i = 0; i < go.Components.Count; i++ )
{
if ( IsPressed( $"{path}/Scope{i}" ) ) return go;
}
}
return null;
}
static bool IsPressed( string path )
{
Gizmo.Path = path;
return Gizmo.Pressed.This;
}
/// <summary>
/// Makes the Object Select tool's drag selections take only what's fully inside the box. The stock one takes anything
/// whose bounds the box touches, and an object's bounds include everything under it (<c>ObjectEditorTool.OnBoxSelect</c>,
/// <c>GameObject.GetBounds</c>), so a small box over a few decorations also took their group and the map they're in.
/// It has no way in, so it runs as before and the result is redone here, from the same box:
/// <list type="bullet">
/// <item><description>An object is taken when it's fully inside with everything under it, and then stands for all
/// of that: what's under it isn't taken as well.</description></item>
/// <item><description>Inside a prefab, or anything else a click selects as a whole (<see cref="IsSelectionBase"/>),
/// only that whole counts.</description></item>
/// <item><description>Hidden and unselectable objects are never taken.</description></item>
/// <item><description>Ctrl takes them out of the selection, Shift adds them, and otherwise they're the selection,
/// as with the stock one (<c>EditorTool.ApplyDragSelection</c>).</description></item>
/// </list>
/// While the drag goes on the scene view (<see cref="ClickFilter.Contains"/>) and the Hierarchy
/// (<see cref="HierarchySelection"/>) show the result; it goes into the selection when the mouse comes up, the frame
/// the stock one finishes. Putting it in every frame would have the tool undo it every frame, and the inspector rebuild
/// and the Hierarchy scroll each time, so the inspector and the move handles show the stock one's picks until then.
/// </summary>
static void WatchDragSelection( Scene scene )
{
var camera = Gizmo.Camera;
if ( Gizmo.WasLeftMousePressed && Gizmo.HasMouseFocus )
{
var before = Unselectable.SessionOf( scene )?.Selection;
_drag = before is null ? null : new DragWatch { Scene = scene, Camera = camera, Before = before.ToHashSet(), Start = Gizmo.CurrentRay, Last = Gizmo.CurrentRay };
return;
}
var drag = _drag;
if ( drag is null || drag.Camera != camera ) return;
// the frame the mouse comes up still counts: the tool finishes its drag selection on it, from the last box. With the
// cursor out of the view the tool leaves its box as it was, and so does this
if ( Gizmo.HasMouseFocus && (Gizmo.IsLeftMouseDown || Gizmo.WasLeftMouseReleased) )
{
if ( Gizmo.IsLeftMouseDown ) drag.Last = Gizmo.CurrentRay;
drag.Result = IsBoxSelecting( drag, out var frustum ) ? BoxResult( scene, drag, frustum ) : null;
// the Hierarchy shows the result as it changes, not what the stock one has put in the selection meanwhile
HierarchySelection.Current?.Sync();
}
// the release, or a later frame if the release went by unseen (a code reload, say)
if ( !Gizmo.IsLeftMouseDown ) Finish( drag );
}
/// <summary>Ends the drag: a drag selection's result goes into the selection, and the Hierarchy shows the selection again.</summary>
static void Finish( DragWatch drag )
{
if ( _drag == drag ) _drag = null;
var result = drag.Result;
var selection = Unselectable.SessionOf( drag.Scene )?.Selection;
if ( result is not null && selection is not null )
{
foreach ( var obj in selection.ToArray() )
{
if ( !result.Contains( obj ) ) selection.Remove( obj );
}
foreach ( var obj in result )
{
if ( !selection.Contains( obj ) ) selection.Add( obj );
}
}
HierarchySelection.Current?.Sync();
}
/// <summary>
/// Whether the Object Select tool is making a drag selection this frame, decided as it decides it
/// (<c>EditorTool.UpdateBoxSelection</c>): the cursor 5 px or more from where the press began, and nothing pressed.
/// Gives the box's frustum.
/// </summary>
static bool IsBoxSelecting( DragWatch drag, out Frustum frustum )
{
frustum = default;
if ( EditorToolManager.CurrentModeName != nameof( ObjectEditorTool ) ) return false;
if ( Gizmo.Pressed.Any ) return false;
var c1 = Gizmo.Camera.ToScreen( drag.Start.Project( 100 ) );
var c2 = Gizmo.Camera.ToScreen( drag.Last.Project( 100 ) );
if ( Vector2.Distance( c1, c2 ) < DragDistance ) return false;
frustum = Gizmo.Camera.GetFrustum( Rect.FromPoints( c1, c2 ) );
return true;
}
/// <summary>What the selection will be when the drag selection in <paramref name="frustum"/> finishes.</summary>
static HashSet<object> BoxResult( Scene scene, DragWatch drag, Frustum frustum )
{
var taken = Take( scene, frustum, go => drag.BoundsOf( go ) );
var result = new HashSet<object>( drag.Before );
if ( Gizmo.IsCtrlPressed )
{
result.ExceptWith( taken );
return result;
}
if ( !Gizmo.IsShiftPressed ) result.RemoveWhere( x => x is GameObject );
result.UnionWith( taken );
return result;
}
/// <summary>
/// The objects a drag selection in <paramref name="frustum"/> takes, before Ctrl or Shift: those fully inside with
/// everything under them (<paramref name="boundsOf"/>, usually <see cref="VisibleBounds"/>), none that are part of
/// one taken, hidden or unselectable, and a prefab only whole.
/// </summary>
internal static HashSet<GameObject> Take( Scene scene, Frustum frustum, System.Func<GameObject, BBox?> boundsOf )
{
var taken = new HashSet<GameObject>();
foreach ( var go in scene.GetAllObjects( true ) )
{
if ( go is Scene || go.Tags.Has( "hidden" ) || Unselectable.Has( go ) ) continue;
// part of a prefab: the prefab is taken whole or not at all
if ( InsideSelectionBase( go ) ) continue;
if ( boundsOf( go ) is { } bounds && frustum.IsInside( bounds, false ) ) taken.Add( go );
}
// an object taken with everything under it stands for all of it
taken.RemoveWhere( go => HasAncestorIn( go, taken ) );
return taken;
}
/// <summary>
/// What the scene view shows of <paramref name="go"/> and everything under it, as one world box, or null when there's
/// nothing. That's what the stock drag selection measures (<c>GameObject.GetBounds</c>: colliders, models, particles),
/// plus sprites, which it leaves out, and the icon of each object that has one, as a point. A
/// <c>SpriteRenderer</c> has no bounds of its own (it isn't a <c>Component.IHasBounds</c>, engine source 26.09.29),
/// so with the stock drag selection no sprite can be drag-selected, and a 2D level's decorations never are.
/// </summary>
internal static BBox? VisibleBounds( GameObject go )
{
BBox? result = null;
void Add( BBox bounds ) => result = result is { } r ? r.AddBBox( bounds ) : bounds;
foreach ( var o in go.GetAllObjects( true ) )
{
if ( o.Tags.Has( "hidden" ) ) continue;
foreach ( var c in o.Components.GetAll( FindMode.EnabledInSelf ) )
{
if ( c is Component.IHasBounds hasBounds ) Add( hasBounds.LocalBounds.Transform( c.WorldTransform ) );
if ( c is SpriteRenderer sprite ) Add( SpriteBounds( sprite ) );
}
if ( o.HasGizmoHandle ) Add( BBox.FromPositionAndSize( o.WorldPosition ) );
}
return result;
}
/// <summary>A sprite's box in the world, worked out as its own hitbox is (<c>SpriteRenderer.DrawGizmos</c>).</summary>
internal static BBox SpriteBounds( SpriteRenderer sprite )
{
var world = sprite.WorldTransform;
var billboard = sprite.Billboard is SpriteRenderer.BillboardMode.Always or SpriteRenderer.BillboardMode.YOnly;
var transform = billboard ? world.WithRotation( new Rotation() ) : world;
transform = transform.WithScale( new Vector3( world.Scale.x, world.Scale.x, world.Scale.z ) );
var pivot = ((sprite.CurrentAnimation?.Origin ?? new Vector2( 0.5f, 0.5f )) - 0.5f) * sprite.Size;
var size = new Vector2( sprite.Size.x, sprite.Size.y );
if ( billboard )
{
size += Vector2.Abs( pivot * 2 );
var angle = MathX.DegreeToRadian( world.Rotation.Roll() );
var cos = MathF.Abs( MathF.Cos( angle ) );
var sin = MathF.Abs( MathF.Sin( angle ) );
size = new Vector2( size.x * cos + size.y * sin, size.x * sin + size.y * cos );
}
var local = BBox.FromPositionAndSize(
billboard ? Vector3.Zero : new Vector3( 0, pivot.x, pivot.y ),
new Vector3( billboard ? size.x : 0.5f, size.x, size.y ) );
return local.Transform( transform );
}
/// <summary>
/// Whether a click selects <paramref name="go"/> as a whole when it lands on something under it, as the scene view
/// decides it (<c>GameObject.FindSelectionBase</c>): a network root, the outermost root of a prefab instance, or an
/// object with a <see cref="SelectionBaseAttribute"/> component.
/// </summary>
static bool IsSelectionBase( GameObject go )
{
// GameObject.IsOutermostPrefabInstanceRoot isn't public; the stock Hierarchy asks this
if ( go.IsNetworkRoot || EditorUtility.Prefabs.IsOuterMostPrefabRoot( go ) ) return true;
return go.Components.GetAll().Any( x => EditorTypeLibrary.GetType( x.GetType() )?.HasAttribute<SelectionBaseAttribute>() == true );
}
static bool InsideSelectionBase( GameObject go )
{
for ( var o = go.Parent; o.IsValid() && o is not Scene; o = o.Parent )
{
if ( IsSelectionBase( o ) ) return true;
}
return false;
}
static bool HasAncestorIn( GameObject go, HashSet<GameObject> objects )
{
for ( var o = go.Parent; o.IsValid() && o is not Scene; o = o.Parent )
{
if ( objects.Contains( o ) ) return true;
}
return false;
}
void EditorEvent.ISceneView.ShowContextMenu( EditorEvent.ShowContextMenuEvent ev )
{
var scene = ev.Session?.Scene;
if ( !Unselectable.AnyIn( scene ) ) return;
ev.Menu.AddSeparator();
if ( ev.Trace is { Hit: true } trace && Unselectable.Source( trace.GameObject ) is { } source )
{
ev.Menu.AddOption( $"Make {source.Name} Selectable", "touch_app", () => Unselectable.Set( new[] { source }, false ) );
}
ev.Menu.AddOption( "Make All Selectable", "touch_app", Unselectable.MakeAllSelectable );
}
/// <summary>
/// A scene viewport's gizmo selection. It passes everything on to the scene's own selection, except a click on an
/// unselectable object, which it holds back for <see cref="ResolvePendingClick"/>.
/// </summary>
sealed class ClickFilter : SelectionSystem, IEnumerable<object>
{
readonly SceneViewportWidget _viewport;
readonly SelectionSystem _selection;
/// <summary>
/// A click clears the selection just before it adds what it clicked (<c>Gizmo.Select</c>). The clear waits for
/// the add: if the click is held back, the clear isn't wanted either.
/// </summary>
bool _clearing;
public ClickFilter( SceneViewportWidget viewport, SelectionSystem selection )
{
_viewport = viewport;
_selection = selection;
}
public override void Clear() => _clearing = true;
public override bool Add( object obj )
{
if ( HoldBack( obj ) ) return false;
Flush();
return _selection.Add( obj );
}
public override bool Remove( object obj )
{
if ( HoldBack( obj ) ) return false;
Flush();
return _selection.Remove( obj );
}
public override bool Set( object obj )
{
Clear();
return Add( obj );
}
/// <summary>
/// Also what the gizmos draw as selected. While a drag selection goes on, that's what it will select when the mouse
/// comes up (<see cref="WatchDragSelection"/>), not what the stock one has put in the selection meanwhile.
/// </summary>
public override bool Contains( object obj ) => _drag?.Result is { } result ? result.Contains( obj ) : _selection.Contains( obj );
public override bool Any() => _drag?.Result is { } result ? result.Count > 0 : _selection.Any();
public override int GetHashCode() => _selection.GetHashCode();
IEnumerator<object> IEnumerable<object>.GetEnumerator() => _selection.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => _selection.GetEnumerator();
/// <summary>A clear still waiting for its add goes through.</summary>
public void Flush()
{
if ( !_clearing ) return;
_clearing = false;
_selection.Clear();
}
bool HoldBack( object obj )
{
if ( obj is not GameObject go || !Unselectable.Has( go ) ) return false;
_clearing = false;
_pending = new PendingClick( _viewport, _selection, _viewport.GizmoInstance.Input );
return true;
}
}
}
Game
library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Hierarchy Extension" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "hierarchyextension" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "trashpandagames" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "trashpandagames.hierarchyextension" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "29" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]
[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-09-30T19:33:36.0810281Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.122.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.122.0")]
Game
library
global using Sandbox; global using System.Collections.Generic; global using System.Linq;
Editor
library
using System.Collections.Generic;
using System.Linq;
namespace HierarchyExtension;
/// <summary>
/// The Hierarchy's selection, so its rows are right while a drag selection is going on in the scene view.
/// <para>
/// The stock drag selection puts whatever its box touches into the scene's selection on every frame of the drag, and
/// the Hierarchy highlights exactly that: parents, maps and unselectable objects that the fully-inside rule
/// (<see cref="ScenePicking"/>) only takes out when the mouse comes up. Taking them out every frame would have the stock
/// one put them back every frame. So the Hierarchy gets a selection of its own instead: its tree's
/// <c>TreeView.Selection</c> is a public setting, and the tree paints rows by asking it, repaints when its hash changes,
/// and scrolls to what's added to it (tools source, <c>BaseItemWidget</c>, 26.09.29).
/// </para>
/// <para>
/// It holds a copy of what the Hierarchy should show: the scene's selection, or during a drag selection what that will
/// select (<see cref="ScenePicking.DragResult"/>). Selecting in the Hierarchy goes straight to the scene's selection,
/// as before. The inspector and the move handles follow the scene's selection, so during a drag they still show the
/// stock one's picks.
/// </para>
/// </summary>
public sealed class HierarchySelection : SelectionSystem
{
static HierarchySelection _current;
/// <summary>The one on the Hierarchy in use, if any.</summary>
internal static HierarchySelection Current => _current;
readonly SelectionSystem _selection;
HierarchySelection( SelectionSystem selection )
{
_selection = selection;
}
/// <summary>
/// Puts one on the Hierarchy's tree, and again whenever the tree is given the scene's selection back: it does that
/// each time it rebuilds, and on a new scene. Then brings its copy up to date.
/// </summary>
[EditorEvent.Frame]
static void Frame()
{
var tree = SceneTreeWidget.Current?.TreeView;
var selection = SceneEditorSession.Active?.Selection;
if ( !tree.IsValid() || selection is null ) return;
if ( tree.Selection == _current && _current?._selection == selection )
{
_current.Sync();
return;
}
// the tree has the scene's own, or one of these from before a code reload that wasn't carried over
var ours = tree.Selection?.GetType().FullName == typeof( HierarchySelection ).FullName;
if ( tree.Selection != selection && !ours ) return;
var view = new HierarchySelection( selection );
// filled before the tree has it, so filling it doesn't scroll the tree
view.Sync();
tree.Selection = view;
_current = view;
}
/// <summary>Makes the copy what the Hierarchy should show now, adding and removing only what differs.</summary>
internal void Sync()
{
IEnumerable<object> target = ScenePicking.DragResult ?? (IEnumerable<object>)_selection;
var wanted = target.ToHashSet();
foreach ( var obj in this.ToArray() )
{
if ( !wanted.Contains( obj ) ) base.Remove( obj );
}
foreach ( var obj in target )
{
if ( !base.Contains( obj ) ) base.Add( obj );
}
}
public override bool Contains( object obj ) => ScenePicking.DragResult is null ? _selection.Contains( obj ) : base.Contains( obj );
public override bool Any() => ScenePicking.DragResult is null ? _selection.Any() : base.Any();
public override bool Add( object obj )
{
var added = _selection.Add( obj );
Sync();
return added;
}
public override bool Remove( object obj )
{
var removed = _selection.Remove( obj );
Sync();
return removed;
}
public override void Clear()
{
_selection.Clear();
Sync();
}
public override bool Set( object obj )
{
var changed = _selection.Set( obj );
Sync();
return changed;
}
}
Editor
library
using System;
using System.Collections.Generic;
namespace HierarchyExtension;
/// <summary>
/// The Hierarchy's column of hands, one on each row just left of where the stock Hide shows its eye. A crossed hand marks
/// an object that can't be selected in the scene view (<see cref="Unselectable"/>); it's faint when that comes from a
/// parent. Hovering a row shows a faint hand to click. A click makes the row's object unselectable, or selectable again;
/// on a row that's unselectable through a parent, it frees that parent.
/// <para>
/// The stock Hierarchy has no way in for this: <c>GameObjectNode.OnPaint</c> paints each row whole, and
/// <c>GameObjectNode.AddGameObjectMenuItems</c> builds the row's context menu, with no event or interface to add to
/// either (tools source, <c>Scene/SceneTree</c>, 26.09.29). So this is a narrow strip laid over the right-hand end of the
/// Hierarchy's rows. It paints the hands and takes the clicks there; everywhere else the tree works as before.
/// </para>
/// </summary>
public sealed class SelectableColumn : Widget
{
/// <summary>An icon's box, as wide as the stock eye's.</summary>
const float IconWidth = 18;
/// <summary>The stock eye's box and its gap: 18 wide, 4 in from the row's end (<c>GameObjectNode.OnPaint</c>).</summary>
const float EyeWidth = 4 + 18;
static SelectableColumn _current;
/// <summary>The column on the Hierarchy in use, if it's open.</summary>
internal static SelectableColumn Current => _current.IsValid() && _current._tree.IsValid() ? _current : null;
readonly TreeView _tree;
/// <summary>The Hierarchy's tree this column lies over.</summary>
internal TreeView Tree => _tree;
/// <summary>A hash of what was painted, so the strip repaints only when rows move or change.</summary>
int _look;
SelectableColumn( TreeView tree ) : base( tree )
{
_tree = tree;
MouseTracking = true;
Cursor = CursorShape.Finger;
Place();
Visible = true;
}
/// <summary>Puts one on the Hierarchy's tree, and again whenever the dock builds a new tree.</summary>
[EditorEvent.Frame]
static void Attach()
{
var tree = SceneTreeWidget.Current?.TreeView;
if ( !tree.IsValid() ) return;
if ( _current.IsValid() && _current._tree == tree ) return;
_current = new SelectableColumn( tree );
}
[EditorEvent.Frame]
void Frame()
{
if ( !_tree.IsValid() ) return;
Place();
var look = Look();
if ( look == _look ) return;
_look = look;
Update();
}
/// <summary>
/// Over the rows' right-hand end, just left of the eye's box, from the top down to the last row in view, so a click
/// on the empty space under the rows still reaches the tree.
/// </summary>
void Place()
{
var right = _tree.CanvasRect.Right;
var bottom = 0f;
foreach ( var (row, _) in Rows() )
{
right = row.Rect.Right;
bottom = row.Rect.Bottom;
}
var position = new Vector2( right - EyeWidth - IconWidth, 0 );
var size = new Vector2( IconWidth, MathF.Min( bottom, _tree.Height ) );
if ( Position != position ) Position = position;
if ( Size != size ) Size = size;
}
/// <summary>The rows in view that show an object, top to bottom, in the tree's coordinates.</summary>
internal IEnumerable<(VirtualWidget Row, GameObject Object)> Rows()
{
var x = _tree.CanvasRect.Left + 1;
var y = 0f;
while ( y < _tree.Height )
{
var row = _tree.GetItemAt( new Vector2( x, y ) );
if ( row is null )
{
y += 2;
continue;
}
if ( row.Object is TreeNode { Value: GameObject go } && go.IsValid() && go is not Scene )
yield return (row, go);
y = MathF.Max( y + 1, row.Rect.Bottom + 1 );
}
}
/// <summary>The object on the row at <paramref name="y"/>, in this strip's coordinates.</summary>
GameObject ObjectAt( float y )
{
var row = _tree.GetItemAt( new Vector2( _tree.CanvasRect.Left + 1, y + Position.y ) );
return row?.Object is TreeNode { Value: GameObject go } && go.IsValid() && go is not Scene ? go : null;
}
/// <summary>The row under the cursor, in the tree or in this strip.</summary>
internal VirtualWidget HoveredRow()
{
var cursor = _tree.FromScreen( Editor.Application.CursorPosition );
if ( !_tree.LocalRect.IsInside( cursor ) ) return null;
return _tree.GetItemAt( new Vector2( _tree.CanvasRect.Left + 1, cursor.y ) );
}
int Look()
{
var hash = new HashCode();
hash.Add( Size );
var hovered = HoveredRow();
foreach ( var (row, go) in Rows() )
{
hash.Add( row.Rect.Top );
hash.Add( go.Id );
hash.Add( go.Active );
hash.Add( Unselectable.IsSet( go ) );
hash.Add( Unselectable.Has( go ) );
hash.Add( row == hovered );
}
return hash.ToHashCode();
}
protected override void OnPaint()
{
var hovered = HoveredRow();
foreach ( var (row, go) in Rows() )
{
var hand = Unselectable.IsSet( go ) ? Hand.Own : Unselectable.Has( go ) ? Hand.FromParent : row == hovered ? Hand.Offered : Hand.None;
PaintHand( new Rect( 0, row.Rect.Top - Position.y, IconWidth, row.Rect.Height ), hand, go.Active );
}
}
/// <summary>What a row's hand shows.</summary>
internal enum Hand
{
/// <summary>Selectable, and not hovered: nothing.</summary>
None,
/// <summary>Selectable and hovered: a faint hand to click.</summary>
Offered,
/// <summary>Unselectable through a parent: a faint crossed hand.</summary>
FromParent,
/// <summary>Unselectable by its own tag: a crossed hand.</summary>
Own,
}
/// <summary>Paints <paramref name="hand"/> in <paramref name="rect"/>, dimmed for a disabled object as its row's text is.</summary>
internal static void PaintHand( Rect rect, Hand hand, bool active )
{
if ( hand == Hand.None ) return;
var icon = hand == Hand.Offered ? "touch_app" : "do_not_touch";
var alpha = hand == Hand.Own ? 0.9f : 0.35f;
if ( !active ) alpha *= 0.5f;
Paint.Pen = Theme.TextControl.WithAlpha( alpha );
Paint.DrawIcon( rect, icon, 14, TextFlag.Center );
}
protected override void OnMousePress( MouseEvent e )
{
base.OnMousePress( e );
// taken here, so the row under the hand isn't selected as well
e.Accepted = true;
if ( !e.LeftMouseButton ) return;
var go = ObjectAt( e.LocalPosition.y );
if ( go is null ) return;
if ( Unselectable.IsSet( go ) ) Unselectable.Set( new[] { go }, false );
else if ( Unselectable.Source( go ) is { } source ) Unselectable.Set( new[] { source }, false );
else Unselectable.Set( new[] { go }, true );
ToolTip = TooltipFor( go );
_look = 0;
Update();
}
protected override void OnMouseMove( MouseEvent e )
{
base.OnMouseMove( e );
ToolTip = TooltipFor( ObjectAt( e.LocalPosition.y ) );
}
/// <summary>The strip lies over the tree, so the wheel over it scrolls the tree, the way the tree scrolls itself.</summary>
protected override void OnMouseWheel( WheelEvent e )
{
if ( !_tree.SmoothScrolling )
{
e.Ignore();
return;
}
e.Accept();
_tree.SmoothScrollTarget += e.Delta < 0 ? _tree.VerticalScrollbar.SingleStep : -_tree.VerticalScrollbar.SingleStep;
}
static string TooltipFor( GameObject go )
{
if ( go is null ) return null;
if ( Unselectable.IsSet( go ) )
return "Not selectable in the scene view: clicks go through it. Click to make it selectable.";
if ( Unselectable.Source( go ) is { } source )
return $"Not selectable in the scene view, because {source.Name} isn't. Click to make {source.Name} selectable.";
return "Click to make it not selectable in the scene view: clicks will go through it to what's behind.";
}
}
Editor
library
using System;
using Editor.Mcp;
namespace HierarchyExtension;
/// <summary>
/// Editor bridge tools for Hierarchy Extension, so an agent driving the editor can see and set which objects are
/// unselectable in the scene view (<see cref="Unselectable"/>), try a click without making it, and see the Hierarchy's
/// hand column, which the bridge can't otherwise see.
/// </summary>
[McpToolset( "hierarchyextension", "Hierarchy Extension - objects unselectable in the scene view: list, set, try a click through them, draw the Hierarchy's hand column" )]
public static class HierarchyExtensionMcpTools
{
/// <summary>
/// The open scene's unselectable objects (each with how many objects under it that makes unselectable too), whether
/// each viewport of the scene view picks through the click filter, and where the Hierarchy's hand column sits
/// against the rows in view.
/// </summary>
[McpTool.ReadOnly( "hierarchy_unselectable" )]
public static object List()
{
var scene = SceneEditorSession.Active?.Scene ?? throw new Exception( "No scene is open" );
var view = SceneViewWidget.Current;
var column = SelectableColumn.Current;
var rows = column?.Rows().ToList();
var firstRow = rows?.Count > 0 ? rows[0].Row.Rect : (Rect?)null;
return new
{
Scene = scene.Name,
Unselectable = scene.GetAllObjects( false ).Where( Unselectable.IsSet ).Select( x => new
{
x.Name,
x.Id,
Under = x.GetAllObjects( false ).Count() - 1,
} ),
Viewports = view?._viewports?.Values.Select( x => new
{
x.Id,
Selection = x.GizmoInstance?.Selection?.GetType().Name,
} ),
Hierarchy = SceneTreeWidget.Current?.TreeView?.Selection is { } shown ? new
{
Selection = shown.GetType().Name,
Shows = shown.Select( x => x is GameObject go ? go.Name : x?.ToString() ),
Selected = SceneEditorSession.Active?.Selection.Select( x => x is GameObject go ? go.Name : x?.ToString() ),
} : null,
Column = column is null ? null : new
{
Position = column.Position,
Size = column.Size,
Visible = column.Visible,
Tree = column.Tree.Size,
Canvas = column.Tree.CanvasRect,
FirstRow = firstRow,
StockEye = firstRow is { } r ? new Rect( r.Right - 22, r.Top, 18, r.Height ) : (Rect?)null,
RowsInView = rows.Count,
},
};
}
/// <summary>
/// Make objects unselectable in the scene view, or selectable again: one undo step, and the scene counts as changed,
/// as with the Hierarchy's hands. Objects under an unselectable one stay unselectable until it's made selectable.
/// </summary>
/// <param name="ids">Object ids, comma separated, from scene_tree or find_game_objects.</param>
/// <param name="selectable">True to make them selectable again, false to make them unselectable.</param>
[McpTool( "hierarchy_set_selectable" )]
public static object SetSelectable( string ids, bool selectable )
{
var objects = Find( ids );
Unselectable.Set( objects, !selectable );
return objects.Select( x => new { x.Name, x.Id, Unselectable = Unselectable.Has( x ), Own = Unselectable.IsSet( x ) } );
}
/// <summary>
/// What a click at a world point in the scene view would select with some objects held out, worked out the way the
/// scene view does it for real (<see cref="ClickThrough"/>). Nothing is clicked: the selection and the scene stay as
/// they are. Uses the viewport last clicked in, and its camera, so the point should be in its view.
/// </summary>
/// <param name="point">The world point to click, "x,y,z".</param>
/// <param name="holdOut">Ids to hold out instead of the unselectable objects, comma separated. Empty holds out nothing.</param>
[McpTool.ReadOnly( "hierarchy_click_through" )]
public static object ClickAt( string point, string holdOut = null )
{
var (session, gizmos, input, world) = CursorAt( point );
IReadOnlyList<GameObject> heldOut = holdOut is null
? session.Scene.GetAllObjects( true ).Where( Unselectable.IsSet ).ToArray()
: Find( holdOut );
var changes = ClickThrough.Run( session.Scene, gizmos.Settings, input, session.Selection, heldOut );
return new
{
Point = world,
HeldOut = heldOut.Select( x => x.Name ),
Changes = changes.Select( x => new
{
Change = x.Change.ToString(),
Target = x.Target is GameObject go ? $"{go.Name} ({go.Id})" : x.Target?.ToString(),
} ),
};
}
/// <summary>
/// What a press at a world point in the scene view lands on, as its gizmos see it: whether anything is pressed (which
/// stops a drag from starting a drag selection), which object it's found on, and whether that object is unselectable,
/// in which case a drag from there lets go of the press. Nothing is pressed for real. Uses the viewport last clicked
/// in, and its camera, so the point should be in its view.
/// </summary>
/// <param name="point">The world point to press, "x,y,z".</param>
[McpTool.ReadOnly( "hierarchy_press_probe" )]
public static object PressProbe( string point )
{
var (session, gizmos, input, world) = CursorAt( point );
var (pressedAnything, pressed) = ClickThrough.WhilePressed( session.Scene, gizmos.Settings, input,
() => (Gizmo.Pressed.Any, ScenePicking.PressedObject( session.Scene, _ => true )) );
return new
{
Point = world,
PressedAnything = pressedAnything,
PressedObject = pressed is null ? null : $"{pressed.Name} ({pressed.Id})",
Unselectable = pressed is not null && Unselectable.Has( pressed ),
};
}
/// <summary>
/// What a drag selection between two world points would take, by Hierarchy Extension's rule (fully inside, a prefab
/// only whole, never what's under a taken object, never unselectable ones), next to what the stock rule takes (any
/// object whose bounds, with everything under it, the box touches). Selects nothing. Uses the viewport last clicked
/// in, and its camera, so both points should be in its view.
/// </summary>
/// <param name="from">One corner of the box, a world point "x,y,z".</param>
/// <param name="to">The opposite corner.</param>
[McpTool.ReadOnly( "hierarchy_box_probe" )]
public static object BoxProbe( string from, string to )
{
var (session, gizmos, _, _) = CursorAt( from );
var camera = gizmos.Input.Camera;
var scene = session.Scene;
var box = Rect.FromPoints( camera.ToScreen( Vector3.Parse( from ) ), camera.ToScreen( Vector3.Parse( to ) ) );
var frustum = camera.GetFrustum( box );
var bounds = new Dictionary<GameObject, BBox>();
BBox BoundsOf( GameObject go )
{
if ( !bounds.TryGetValue( go, out var b ) ) bounds[go] = b = go.GetBounds();
return b;
}
// ObjectEditorTool.OnBoxSelect
var stock = scene.GetAllObjects( true )
.Where( go => go is not Scene && !go.Tags.Has( "hidden" ) )
.Where( go => !(BoundsOf( go ).Size.AlmostEqual( 0.0f ) && !go.HasGizmoHandle) && frustum.IsInside( BoundsOf( go ), true ) )
.Select( x => x.Name );
return new
{
ScreenBox = box,
Takes = ScenePicking.Take( scene, frustum, ScenePicking.VisibleBounds ).Select( x => x.Name ),
StockTakes = stock,
};
}
/// <summary>The session, gizmo instance and cursor input for pointing at <paramref name="point"/> in the scene view.</summary>
static (SceneEditorSession Session, Gizmo.Instance Gizmos, Gizmo.Inputs Input, Vector3 World) CursorAt( string point )
{
var view = SceneViewWidget.Current ?? throw new Exception( "No scene view is open" );
var viewport = view.LastSelectedViewportWidget ?? view._viewports?.Values.FirstOrDefault() ?? throw new Exception( "The scene view has no viewport" );
var gizmos = viewport.GizmoInstance;
var camera = gizmos.Input.Camera ?? throw new Exception( "The viewport hasn't drawn yet" );
var world = Vector3.Parse( point );
var forward = camera.Rotation.Forward;
var input = gizmos.Input;
input.CursorRay = camera.Ortho
? new Ray( world - forward * Vector3.Dot( world - camera.Position, forward ), forward )
: new Ray( camera.Position, (world - camera.Position).Normal );
input.CursorPosition = camera.ToScreen( world );
input.Modifiers = KeyboardModifiers.None;
input.LeftMouse = false;
return (view.Session, gizmos, input, world);
}
/// <summary>
/// Draws the Hierarchy's rows in view into a PNG as the dock paints them (not the expand arrows), with the hand
/// column over them. The bridge can't see editor windows, so this is how to check the column lines up. The preview
/// ids draw as if they were unselectable, and the hovered one with its faint hand, without changing anything.
/// </summary>
/// <param name="path">Where to write the PNG, an absolute path.</param>
/// <param name="preview">Ids to draw as unselectable, comma separated, as if their hands had been clicked.</param>
/// <param name="hovered">An id to draw as the row under the cursor.</param>
[McpTool.ReadOnly( "hierarchy_render_rows" )]
public static object RenderRows( string path, string preview = null, string hovered = null )
{
var previewed = preview is null ? new HashSet<GameObject>() : Find( preview ).ToHashSet();
var hoveredObject = hovered is null ? null : Find( hovered ).FirstOrDefault();
SelectableColumn.Hand HandOf( GameObject go )
{
if ( Unselectable.IsSet( go ) || previewed.Contains( go ) ) return SelectableColumn.Hand.Own;
for ( var o = go.Parent; o.IsValid() && o is not Scene; o = o.Parent )
{
if ( Unselectable.IsSet( o ) || previewed.Contains( o ) ) return SelectableColumn.Hand.FromParent;
}
return go == hoveredObject ? SelectableColumn.Hand.Offered : SelectableColumn.Hand.None;
}
var column = SelectableColumn.Current ?? throw new Exception( "The Hierarchy isn't open" );
var tree = column.Tree;
var rows = column.Rows().ToList();
if ( rows.Count == 0 ) throw new Exception( "No rows in view" );
var top = rows.Min( x => x.Row.Rect.Top );
var bottom = rows.Max( x => x.Row.Rect.Bottom );
var pixmap = new Pixmap( (int)tree.Width, (int)MathF.Ceiling( bottom - top ) );
using ( Paint.ToPixmap( pixmap ) )
{
Paint.Translate( new Vector2( 0, -top ) );
Paint.ClearPen();
Paint.SetBrush( Theme.ControlBackground );
Paint.DrawRect( new Rect( 0, top, tree.Width, bottom - top ) );
foreach ( var (row, go) in rows )
{
if ( row.Object is not TreeNode node ) continue;
// as TreeView.PaintItem shifts a row by its depth before the node paints it
var indent = tree.IndentWidth * row.Column + tree.ExpandWidth;
row.Rect.Left += indent;
try
{
node.OnPaint( row );
}
finally
{
row.Rect.Left -= indent;
}
SelectableColumn.PaintHand( new Rect( column.Position.x, row.Rect.Top, column.Size.x, row.Rect.Height ), HandOf( go ), go.Active );
}
}
pixmap.SavePng( path );
return new { Path = path, Size = new Vector2( pixmap.Width, pixmap.Height ), Rows = rows.Select( x => x.Object.Name ) };
}
static GameObject[] Find( string ids )
{
var scene = SceneEditorSession.Active?.Scene ?? throw new Exception( "No scene is open" );
var found = new List<GameObject>();
foreach ( var id in (ids ?? "").Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ) )
{
var go = Guid.TryParse( id, out var guid ) ? scene.Directory.FindByGuid( guid ) : null;
found.Add( go ?? throw new Exception( $"No object with id {id}" ) );
}
return found.ToArray();
}
}
UnitTest
library
global using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class TestInit
{
public static Sandbox.TestAppSystem AppSystem;
[AssemblyInitialize]
public static void AssemblyInitialize( TestContext context )
{
AppSystem = new Sandbox.TestAppSystem();
AppSystem.Init();
}
[AssemblyCleanup]
public static void AssemblyCleanup()
{
AppSystem.Shutdown();
}
}
Game
library
global using Sandbox; global using System.Collections.Generic; global using System.Linq;
Debug: View Raw JSON Response
{
"TotalCount": 13,
"Files": [
{
"Ident": "trashpandagames.hierarchyextension",
"Path": "Editor/Unselectable.cs",
"FileName": "Unselectable.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 398910,
"IsPrivate": false,
"Code": "using System.Collections.Generic;\nusing System.Linq;\n\nnamespace HierarchyExtension;\n\n/// <summary>\n/// Objects that can't be selected in the scene view. A click goes through them to whatever is behind, and a drag\n/// selection leaves them out. They still select from the Hierarchy, so they can be inspected and edited as usual.\n/// <para>\n/// An object is made unselectable with the <see cref=\"Tag\"/> tag, the way the stock Hide marks hidden objects with\n/// \"hidden\". It saves with the scene, shows in the object's Tags, takes undo, and counts as an override on a prefab\n/// instance. Tags pass down to children, so everything under an unselectable object is unselectable too.\n/// </para>\n/// <para>\n/// Set it with the hand on each Hierarchy row (<see cref=\"SelectableColumn\"/>), with <b>Scene > View > Toggle\n/// Selectable</b> (Alt+L) for the selected objects, or with <b>Make All Selectable</b>. <see cref=\"ScenePicking\"/>\n/// makes the scene view honour it.\n/// </para>\n/// </summary>\npublic static class Unselectable\n{\n\t/// <summary>The tag that makes an object, and everything under it, unselectable in the scene view.</summary>\n\tpublic const string Tag = \"unselectable\";\n\n\t/// <summary>Whether <paramref name=\"go\"/> can't be selected in the scene view, from its own tag or a parent's.</summary>\n\tpublic static bool Has( GameObject go ) => go.IsValid() && go is not Scene && go.Tags.Has( Tag );\n\n\t/// <summary>Whether <paramref name=\"go\"/> has the tag itself, not only from a parent.</summary>\n\tpublic static bool IsSet( GameObject go ) => go.IsValid() && go is not Scene && go.Tags.Has( Tag, false );\n\n\t/// <summary>\n\t/// The object whose tag makes <paramref name=\"go\"/> unselectable: itself, or the nearest parent with the tag.\n\t/// Null when it's selectable.\n\t/// </summary>\n\tpublic static GameObject Source( GameObject go )\n\t{\n\t\tfor ( var o = go; o.IsValid() && o is not Scene; o = o.Parent )\n\t\t{\n\t\t\tif ( IsSet( o ) ) return o;\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t/// <summary>\n\t/// Make <paramref name=\"objects\"/> unselectable in the scene view, or selectable again, as one undo step. Objects\n\t/// that are that way already are left alone. Making an object selectable only takes its own tag off, so one under\n\t/// an unselectable parent stays unselectable; pass <see cref=\"Source\"/> to free it.\n\t/// </summary>\n\tpublic static void Set( IEnumerable<GameObject> objects, bool unselectable )\n\t{\n\t\tvar changing = objects.Where( x => x.IsValid() && x is not Scene && IsSet( x ) != unselectable ).Distinct().ToArray();\n\t\tif ( changing.Length == 0 ) return;\n\n\t\tvar session = SessionOf( changing[0] );\n\t\tif ( session is null ) return;\n\n\t\tvar name = unselectable ? \"Make Unselectable\" : \"Make Selectable\";\n\t\tif ( changing.Length == 1 ) name = $\"{name}: {changing[0].Name}\";\n\n\t\tusing var sceneScope = session.Scene.Push();\n\n\t\t// only the objects' own properties, which is where tags live: a tile layer's children and components can be big\n\t\tusing ( session.UndoScope( name ).WithGameObjectChanges( changing, GameObjectUndoFlags.Properties ).Push() )\n\t\t{\n\t\t\tforeach ( var go in changing )\n\t\t\t{\n\t\t\t\tif ( unselectable ) go.Tags.Add( Tag );\n\t\t\t\telse go.Tags.Remove( Tag );\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Alt+L: make the selected objects unselectable in the scene view or, when they all are already, selectable again.\n\t/// One that's unselectable through a parent frees that parent, and so everything under it.\n\t/// </summary>\n\t[Menu( \"Editor\", \"Scene/View/Toggle Selectable\", \"do_not_touch\" )]\n\t[Shortcut( \"editor.toggle-selectable\", \"Alt+L\" )]\n\tpublic static void ToggleSelected()\n\t{\n\t\tvar session = SceneEditorSession.Active;\n\t\tif ( session is null ) return;\n\n\t\tvar selected = session.Selection.OfType<GameObject>().Where( x => x.IsValid() && x is not Scene ).ToArray();\n\t\tif ( selected.Length == 0 ) return;\n\n\t\tif ( selected.Any( x => !Has( x ) ) ) Set( selected, true );\n\t\telse Set( selected.Select( Source ), false );\n\t}\n\n\t/// <summary>Make everything in the open scene selectable in the scene view again.</summary>\n\t[Menu( \"Editor\", \"Scene/View/Make All Selectable\", \"touch_app\" )]\n\tpublic static void MakeAllSelectable()\n\t{\n\t\tvar scene = SceneEditorSession.Active?.Scene;\n\t\tif ( !scene.IsValid() ) return;\n\n\t\tSet( scene.GetAllObjects( false ).Where( IsSet ), false );\n\t}\n\n\t/// <summary>Whether anything in <paramref name=\"scene\"/> is unselectable. Cheap enough to ask on every click.</summary>\n\tpublic static bool AnyIn( Scene scene )\n\t{\n\t\tif ( !scene.IsValid() ) return false;\n\n\t\tforeach ( var go in scene.GetAllObjects( true ) )\n\t\t{\n\t\t\tif ( IsSet( go ) ) return true;\n\t\t}\n\n\t\treturn false;\n\t}\n\n\t/// <summary>The editor session whose scene holds <paramref name=\"go\"/>, the play session's included.</summary>\n\tpublic static SceneEditorSession SessionOf( GameObject go ) => SessionOf( go?.Scene );\n\n\t/// <summary>The editor session of <paramref name=\"scene\"/>, the play session's included.</summary>\n\tpublic static SceneEditorSession SessionOf( Scene scene )\n\t{\n\t\tif ( scene is null ) return null;\n\n\t\treturn SceneEditorSession.All.FirstOrDefault( x => x.Scene == scene );\n\t}\n}\n"
},
{
"Ident": "trashpandagames.hierarchyextension",
"Path": "Editor/Assembly.cs",
"FileName": "Assembly.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 398910,
"IsPrivate": false,
"Code": "global using Sandbox;\nglobal using Editor;\nglobal using System.Collections.Generic;\nglobal using System.Linq;\n"
},
{
"Ident": "trashpandagames.hierarchyextension",
"Path": "Editor/SelectionOutline.cs",
"FileName": "SelectionOutline.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 398910,
"IsPrivate": false,
"Code": "using System.Collections.Generic;\nusing System.Linq;\n\nnamespace HierarchyExtension;\n\n/// <summary>\n/// Outlines what's selected in the scene view: one box around each selected object and everything under it (its\n/// sprites, models, colliders, particles and icons: <see cref=\"ScenePicking.VisibleBounds\"/>), a thin line drawn over\n/// everything in the editor's own selected colour. An object inside another selected one gets no box of its own; the\n/// other's covers it. During a drag selection it outlines what the drag will select (<see cref=\"ScenePicking.DragResult\"/>).\n/// <para>\n/// Why: the stock scene view only outlines an object's own sprite or model (<c>SpriteRenderer.DrawGizmos</c> asks\n/// <c>Gizmo.IsSelected</c> in the sprite's own object; the viewport's <c>DrawSelection</c> takes the selected object's\n/// own <c>ModelRenderer</c>s). A prefab, or a group, keeps its sprites on its children, and selecting it selects the\n/// root, so a selected decoration, or Sera, showed nothing but the move handle (tools and engine source, 26.09.29).\n/// </para>\n/// </summary>\npublic static class SelectionOutline\n{\n\t/// <summary>Draws the outlines. Called from the scene view's gizmo pass (<see cref=\"ScenePicking\"/>).</summary>\n\tinternal static void Draw( IEnumerable<object> selected )\n\t{\n\t\tvar objects = selected.OfType<GameObject>().Where( x => x.IsValid() && x is not Scene ).ToHashSet();\n\t\tvar boxes = new List<BBox>();\n\n\t\tforeach ( var go in objects )\n\t\t{\n\t\t\tif ( HasAncestorIn( go, objects ) ) continue;\n\n\t\t\t// a point (an icon and nothing else) is shown by the icon itself\n\t\t\tif ( ScenePicking.VisibleBounds( go ) is { } bounds && bounds.Size.Length > 0.01f ) boxes.Add( bounds );\n\t\t}\n\n\t\tif ( boxes.Count == 0 ) return;\n\n\t\t// One colour and one thickness, so it's all one batch. A dark edge under the line looked better, but it has to be a\n\t\t// thicker line, and a change of thickness starts another batch (Gizmo.Draw.CanReuseVertexObject). Batches drawn\n\t\t// over everything have no order between them, so after a reselect the edge sometimes landed on top of the line.\n\t\tusing var scope = Gizmo.Scope( \"hierarchyextension-selection\" );\n\t\tGizmo.Transform = global::Transform.Zero;\n\t\tGizmo.Draw.IgnoreDepth = true;\n\t\tGizmo.Draw.Color = Gizmo.Colors.Selected;\n\t\tGizmo.Draw.LineThickness = 1.5f;\n\n\t\tforeach ( var box in boxes ) Gizmo.Draw.LineBBox( box );\n\t}\n\n\tstatic bool HasAncestorIn( GameObject go, HashSet<GameObject> objects )\n\t{\n\t\tfor ( var o = go.Parent; o.IsValid() && o is not Scene; o = o.Parent )\n\t\t{\n\t\t\tif ( objects.Contains( o ) ) return true;\n\t\t}\n\n\t\treturn false;\n\t}\n}\n"
},
{
"Ident": "trashpandagames.hierarchyextension",
"Path": "Editor/ClickThrough.cs",
"FileName": "ClickThrough.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 398910,
"IsPrivate": false,
"Code": "using System.Collections.Generic;\nusing System.Linq;\n\nnamespace HierarchyExtension;\n\n/// <summary>\n/// Works out what a click in the scene view would select if the unselectable objects weren't there. It plays the click\n/// again through a gizmo instance of its own, over the scene's own gizmos, with the unselectable objects held out:\n/// the engine picks, so whatever it picks normally (render meshes, sprites, handles, a component's hitboxes, selection\n/// bases) is picked the same way.\n/// <para>\n/// Why not stop the engine picking them in the first place: the scene view has no per-object switch. The only objects\n/// it skips are ones tagged \"hidden\" or flagged Hidden, and both stop them drawing or listing too (engine source,\n/// <c>GameObject.DrawGizmos</c> and <c>SceneViewportWidget.UpdateHovered</c>, 26.09.29). So the objects are flagged\n/// Hidden only while the click is replayed here, and put back straight after, before anything draws or lists them.\n/// </para>\n/// </summary>\npublic static class ClickThrough\n{\n\t/// <summary>A change a click makes to the selection, in the order the click makes them.</summary>\n\tpublic enum Change { Clear, Add, Remove }\n\n\t/// <summary>\n\t/// The selection changes a click with <paramref name=\"input\"/> would make in <paramref name=\"scene\"/> if the\n\t/// unselectable objects weren't there, in order. Empty when it would hit nothing. The selection isn't touched:\n\t/// the click reads <paramref name=\"selection\"/>, to know what's selected, and only records what it would do.\n\t/// Also used by <see cref=\"ScenePicking\"/>; call it outside any gizmo drawing.\n\t/// </summary>\n\tpublic static List<(Change Change, object Target)> Run( Scene scene, Gizmo.SceneSettings settings, Gizmo.Inputs input, SelectionSystem selection )\n\t{\n\t\treturn Run( scene, settings, input, selection, scene.GetAllObjects( true ).Where( Unselectable.IsSet ).ToArray() );\n\t}\n\n\t/// <summary>The same with <paramref name=\"heldOut\"/>, and everything under them, held out.</summary>\n\tpublic static List<(Change Change, object Target)> Run( Scene scene, Gizmo.SceneSettings settings, Gizmo.Inputs input, SelectionSystem selection, IReadOnlyList<GameObject> heldOut )\n\t{\n\t\tvar recorder = new Recorder( selection );\n\t\tif ( !scene.IsValid() ) return recorder.Changes;\n\n\t\t// a GameObject flagged Hidden draws no gizmos, and nor do its children\n\t\tvar flagged = new List<GameObject>();\n\t\tforeach ( var go in heldOut )\n\t\t{\n\t\t\tif ( !go.IsValid() || go.Flags.Contains( GameObjectFlags.Hidden ) ) continue;\n\n\t\t\tgo.Flags |= GameObjectFlags.Hidden;\n\t\t\tflagged.Add( go );\n\t\t}\n\n\t\ttry\n\t\t{\n\t\t\tusing var instance = new Gizmo.Instance { Settings = settings, Selection = recorder };\n\n\t\t\t// The gizmo state reads the mouse a frame late: a press is seen the frame after the button goes down, and a\n\t\t\t// click the frame after it comes up, when the object hovered then is the one pressed.\n\t\t\tinput.IsHovered = true;\n\t\t\tFrame( instance, scene, input, false, heldOut, draw: false );\n\t\t\tFrame( instance, scene, input, true, heldOut );\n\t\t\tFrame( instance, scene, input, false, heldOut );\n\t\t\tFrame( instance, scene, input, false, heldOut );\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\tforeach ( var go in flagged )\n\t\t\t{\n\t\t\t\tif ( go.IsValid() ) go.Flags &= ~GameObjectFlags.Hidden;\n\t\t\t}\n\t\t}\n\n\t\treturn recorder.Changes;\n\t}\n\n\t/// <summary>\n\t/// Plays the button going down at <paramref name=\"input\"/>, nothing held out, and asks <paramref name=\"ask\"/> on the\n\t/// frame the press is seen, as the scene view's own code would see it (<c>Gizmo.Pressed</c>). For the bridge's\n\t/// press probe; call it outside any gizmo drawing.\n\t/// </summary>\n\tpublic static T WhilePressed<T>( Scene scene, Gizmo.SceneSettings settings, Gizmo.Inputs input, System.Func<T> ask )\n\t{\n\t\tvar none = System.Array.Empty<GameObject>();\n\n\t\tusing var instance = new Gizmo.Instance { Settings = settings, Selection = new Recorder( null ) };\n\n\t\tinput.IsHovered = true;\n\t\tFrame( instance, scene, input, false, none, draw: false );\n\t\tFrame( instance, scene, input, true, none );\n\n\t\tinput.LeftMouse = true;\n\t\tinstance.Input = input;\n\n\t\tusing var gizmos = instance.Push();\n\t\tusing var sceneScope = scene.Push();\n\t\treturn ask();\n\t}\n\n\tstatic void Frame( Gizmo.Instance instance, Scene scene, Gizmo.Inputs input, bool leftMouse, IReadOnlyList<GameObject> heldOut, bool draw = true )\n\t{\n\t\tinput.LeftMouse = leftMouse;\n\t\tinstance.Input = input;\n\n\t\tusing var gizmos = instance.Push();\n\t\tusing var sceneScope = scene.Push();\n\n\t\tif ( !draw ) return;\n\n\t\tHoverRenderMeshes( scene, heldOut );\n\t\tscene.EditorDraw();\n\t}\n\n\t/// <summary>\n\t/// What the scene view does before the gizmos draw: trace the render meshes under the cursor and hover the object\n\t/// hit. The stock one is private (<c>SceneViewportWidget.UpdateHovered</c>); this is it, holding out the objects.\n\t/// </summary>\n\tstatic void HoverRenderMeshes( Scene scene, IReadOnlyList<GameObject> heldOut )\n\t{\n\t\tif ( !Gizmo.Settings.Selection ) return;\n\n\t\tvar trace = scene.Trace.Ray( Gizmo.CurrentRay, Gizmo.RayDepth )\n\t\t\t.UseRenderMeshes( true )\n\t\t\t.UsePhysicsWorld( false )\n\t\t\t.WithoutTags( \"hidden\" );\n\n\t\tforeach ( var go in heldOut )\n\t\t{\n\t\t\tif ( go.IsValid() ) trace = trace.IgnoreGameObjectHierarchy( go );\n\t\t}\n\n\t\tvar tr = trace.Run();\n\t\tif ( !tr.Hit || !tr.Component.IsValid() ) return;\n\n\t\tusing ( Gizmo.ObjectScope( tr.GameObject, tr.GameObject.WorldTransform ) )\n\t\t{\n\t\t\tGizmo.Hitbox.DepthBias = 1;\n\t\t\tGizmo.Hitbox.TrySetHovered( tr.Distance );\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// The replayed click's selection: it answers what's selected from the real one, and writes down the changes\n\t/// instead of making them.\n\t/// </summary>\n\tsealed class Recorder : SelectionSystem\n\t{\n\t\treadonly SelectionSystem _selection;\n\n\t\tpublic readonly List<(Change Change, object Target)> Changes = new();\n\n\t\tpublic Recorder( SelectionSystem selection )\n\t\t{\n\t\t\t_selection = selection;\n\t\t}\n\n\t\tpublic override bool Contains( object obj ) => _selection?.Contains( obj ) ?? false;\n\n\t\tpublic override bool Any() => _selection?.Any() ?? false;\n\n\t\tpublic override void Clear() => Changes.Add( (Change.Clear, null) );\n\n\t\tpublic override bool Add( object obj )\n\t\t{\n\t\t\tif ( obj is null ) return false;\n\n\t\t\tChanges.Add( (Change.Add, obj) );\n\t\t\treturn true;\n\t\t}\n\n\t\tpublic override bool Remove( object obj )\n\t\t{\n\t\t\tif ( obj is null ) return false;\n\n\t\t\tChanges.Add( (Change.Remove, obj) );\n\t\t\treturn true;\n\t\t}\n\n\t\tpublic override bool Set( object obj )\n\t\t{\n\t\t\tClear();\n\t\t\treturn Add( obj );\n\t\t}\n\t}\n}\n"
},
{
"Ident": "trashpandagames.hierarchyextension",
"Path": "UnitTests/LibraryTest.cs",
"FileName": "LibraryTest.cs",
"PackageType": "library",
"CodeKind": "UnitTest",
"AssetVersionId": 398910,
"IsPrivate": false,
"Code": "using Sandbox;\r\n\r\n[TestClass]\r\npublic partial class LibraryTests\r\n{\r\n\t[TestMethod]\r\n\tpublic void SceneTest()\r\n\t{\r\n\t\tvar scene = new Scene();\r\n\t\tusing ( scene.Push() )\r\n\t\t{\r\n\t\t\tvar go = new GameObject();\r\n\r\n\t\t\tAssert.AreEqual( 1, scene.Directory.GameObjectCount );\r\n\t\t}\r\n\t}\r\n\r\n}\r\n"
},
{
"Ident": "trashpandagames.hierarchyextension",
"Path": "Editor/ScenePicking.cs",
"FileName": "ScenePicking.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 398910,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace HierarchyExtension;\n\n/// <summary>\n/// Makes the scene view honour <see cref=\"Unselectable\"/>, and makes its drag selections take only what's fully inside.\n/// <list type=\"bullet\">\n/// <item><description><b>Clicks</b>: each scene viewport's gizmos select through a <see cref=\"ClickFilter\"/>. A click\n/// that would select an unselectable object is held back. On the next editor frame <see cref=\"ClickThrough\"/> plays it\n/// again without the unselectable objects, and its selection is made instead, as one undo step. With nothing behind,\n/// the click deselects, like a click on empty space.</description></item>\n/// <item><description><b>Drags</b> that start on an unselectable object are drags on empty space: drag selections,\n/// and the camera, take them as they would there (<see cref=\"LetGoOfDraggedPress\"/>).</description></item>\n/// <item><description><b>Drag selections</b> with the Object Select tool take only objects fully inside the box, never\n/// unselectable ones, and a prefab only whole (<see cref=\"WatchDragSelection\"/>).</description></item>\n/// <item><description><b>Right-clicking</b> the scene view offers to make unselectable objects selectable again.</description></item>\n/// </list>\n/// <para>\n/// The filter goes where the engine lets it: a viewport's <c>GizmoInstance.Selection</c> is a public setting, and only\n/// the gizmos' clicks select through it (tools source, <c>SceneViewportWidget</c>, 26.09.29). The Hierarchy, the\n/// inspector, tools and code select through the session's selection, so they can still pick anything.\n/// </para>\n/// </summary>\npublic sealed class ScenePicking : EditorEvent.ISceneView\n{\n\t/// <summary>How far the cursor goes before a drag selection starts (<c>EditorTool.UpdateBoxSelection</c>), in pixels.</summary>\n\tconst float DragDistance = 5;\n\n\tstatic ScenePicking _listener;\n\tstatic PendingClick _pending;\n\tstatic DragWatch _drag;\n\n\t/// <summary>Whether the press going on has been checked for landing on an unselectable object.</summary>\n\tstatic bool _pressChecked;\n\n\t/// <summary>\n\t/// While a drag selection is going on in the scene view: what it will select when the mouse comes up. Null otherwise.\n\t/// The Hierarchy shows this meanwhile (<see cref=\"HierarchySelection\"/>).\n\t/// </summary>\n\tinternal static HashSet<object> DragResult => _drag?.Result;\n\n\t/// <summary>A click that was held back, with what the viewport's gizmos saw when it landed.</summary>\n\tsealed record PendingClick( SceneViewportWidget Viewport, SelectionSystem Selection, Gizmo.Inputs Input );\n\n\t/// <summary>A press begun in a viewport (known by its camera), and the drag selection it may become.</summary>\n\tsealed class DragWatch\n\t{\n\t\tpublic Scene Scene;\n\t\tpublic SceneCamera Camera;\n\n\t\t/// <summary>Editor frames in a row the left button has been up while this drag still went on.</summary>\n\t\tpublic int FramesUp;\n\n\t\t/// <summary>What was selected when the press began.</summary>\n\t\tpublic HashSet<object> Before;\n\n\t\t/// <summary>The cursor's ray when the press began, and on the last frame the button was down, as the tool takes them.</summary>\n\t\tpublic Ray Start, Last;\n\n\t\t/// <summary>While it's a drag selection: what the selection will be when the mouse comes up. Null otherwise.</summary>\n\t\tpublic HashSet<object> Result;\n\n\t\treadonly Dictionary<GameObject, BBox?> _bounds = new();\n\n\t\t/// <summary>\n\t\t/// <see cref=\"VisibleBounds\"/>, worked out once per drag, as the tool does its bounds: nothing moves while the\n\t\t/// mouse is busy with a drag selection, and they're slow to get.\n\t\t/// </summary>\n\t\tpublic BBox? BoundsOf( GameObject go )\n\t\t{\n\t\t\tif ( !_bounds.TryGetValue( go, out var bounds ) ) _bounds[go] = bounds = VisibleBounds( go );\n\t\t\treturn bounds;\n\t\t}\n\t}\n\n\t[EditorEvent.Frame]\n\tstatic void Frame()\n\t{\n\t\tif ( _listener is null )\n\t\t{\n\t\t\t_listener = new ScenePicking();\n\t\t\tEditorEvent.Register( _listener );\n\t\t}\n\n\t\tInstallFilters();\n\t\tResolvePendingClick();\n\n\t\t// a drag whose viewport stopped drawing before its release was seen: end it once the button has been up a while\n\t\tif ( _drag is { } drag )\n\t\t{\n\t\t\tdrag.FramesUp = Editor.Application.MouseButtons.HasFlag( MouseButtons.Left ) ? 0 : drag.FramesUp + 1;\n\t\t\tif ( drag.FramesUp > 3 ) Finish( drag );\n\t\t}\n\t}\n\n\t/// <summary>A code reload keeps the listener; register it again in case the reload dropped it.</summary>\n\t[EditorEvent.Hotload]\n\tstatic void OnHotload()\n\t{\n\t\tif ( _listener is null ) return;\n\n\t\tEditorEvent.Unregister( _listener );\n\t\tEditorEvent.Register( _listener );\n\t}\n\n\t/// <summary>\n\t/// Puts a filter on every viewport of the scene view in use, and on any it's lost: a viewport sets its selection\n\t/// again when play starts or stops. Also lets through a clear that's still waiting for its add.\n\t/// </summary>\n\tstatic void InstallFilters()\n\t{\n\t\tvar view = SceneViewWidget.Current;\n\t\tif ( !view.IsValid() || view._viewports is null ) return;\n\n\t\tforeach ( var viewport in view._viewports.Values )\n\t\t{\n\t\t\tvar gizmos = viewport.IsValid() ? viewport.GizmoInstance : null;\n\t\t\tif ( gizmos?.Selection is null ) continue;\n\n\t\t\tif ( gizmos.Selection is ClickFilter filter )\n\t\t\t{\n\t\t\t\tfilter.Flush();\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// one from before a code reload that wasn't carried over: leave it rather than stack another on it\n\t\t\tif ( gizmos.Selection.GetType().FullName == typeof( ClickFilter ).FullName ) continue;\n\n\t\t\tgizmos.Selection = new ClickFilter( viewport, gizmos.Selection );\n\t\t}\n\t}\n\n\tstatic void ResolvePendingClick()\n\t{\n\t\tvar click = _pending;\n\t\tif ( click is null ) return;\n\t\t_pending = null;\n\n\t\tvar session = SceneEditorSession.All.FirstOrDefault( x => x.Selection == click.Selection );\n\t\tvar scene = session?.Scene;\n\t\tif ( !scene.IsValid() || !click.Viewport.IsValid() ) return;\n\n\t\tvar changes = ClickThrough.Run( scene, click.Viewport.GizmoInstance.Settings, click.Input, click.Selection );\n\n\t\tusing var sceneScope = scene.Push();\n\t\tusing ( session.UndoScope( UndoName( changes ) ).Push() )\n\t\t{\n\t\t\tif ( changes.Count == 0 )\n\t\t\t{\n\t\t\t\tclick.Selection.Clear();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tforeach ( var (change, target) in changes )\n\t\t\t{\n\t\t\t\tswitch ( change )\n\t\t\t\t{\n\t\t\t\t\tcase ClickThrough.Change.Clear: click.Selection.Clear(); break;\n\t\t\t\t\tcase ClickThrough.Change.Add: click.Selection.Add( target ); break;\n\t\t\t\t\tcase ClickThrough.Change.Remove: click.Selection.Remove( target ); break;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/// <summary>Named as the stock click's undo steps are: \"Select {name}\", or \"Deselect all\" for empty space.</summary>\n\tstatic string UndoName( List<(ClickThrough.Change Change, object Target)> changes )\n\t{\n\t\tvar last = changes.LastOrDefault( x => x.Change != ClickThrough.Change.Clear );\n\t\tif ( last.Target is GameObject go ) return last.Change == ClickThrough.Change.Add ? $\"Select {go.Name}\" : $\"Deselect {go.Name}\";\n\n\t\treturn \"Deselect all\";\n\t}\n\n\t/// <summary>Runs in each viewport's frame after its tool, before the scene's gizmos.</summary>\n\tvoid EditorEvent.ISceneView.DrawGizmos( Scene scene )\n\t{\n\t\tLetGoOfDraggedPress( scene );\n\t\tWatchDragSelection( scene );\n\n\t\t// what's selected, or during a drag selection what it will select\n\t\tvar selected = DragResult ?? (IEnumerable<object>)Unselectable.SessionOf( scene )?.Selection;\n\t\tif ( selected is not null ) SelectionOutline.Draw( selected );\n\t}\n\n\t/// <summary>\n\t/// A press on an unselectable object still counts as a press on something: the scene view hovers what's under the\n\t/// cursor before anyone clicks, with no way to leave objects out. While anything is pressed, a drag doesn't start a\n\t/// drag selection (<c>EditorTool.UpdateBoxSelection</c>) or move the camera (<c>SceneViewportWidget</c>), so a drag\n\t/// that began on a backdrop did nothing. Once such a press has moved as far as a drag selection waits for, let go of\n\t/// it (<c>Gizmo.Pressed.ClearPath</c>, as the engine does itself for a dragged object press): from the next frame the\n\t/// drag is a drag on empty space, and a drag selection starts from where the press began. A press that doesn't move\n\t/// stays a click, which the <see cref=\"ClickFilter\"/> plays through to what's behind.\n\t/// </summary>\n\tstatic void LetGoOfDraggedPress( Scene scene )\n\t{\n\t\tif ( !Gizmo.HasMouseFocus ) return;\n\t\tif ( Gizmo.WasLeftMousePressed ) _pressChecked = false;\n\n\t\tif ( _pressChecked || !Gizmo.IsLeftMouseDown || !Gizmo.Pressed.Any ) return;\n\t\tif ( Gizmo.Pressed.CursorDelta.Length < DragDistance ) return;\n\n\t\t_pressChecked = true;\n\t\tif ( PressedUnselectable( scene ).IsValid() ) Gizmo.Pressed.ClearPath();\n\t}\n\n\t/// <summary>\n\t/// The unselectable object the press is on, or null. The gizmos name what's pressed by its path: an object's is\n\t/// \"object-{hash}\" (<c>Gizmo.ObjectScope</c>), its handle's \".../Handle\", and its components' hitboxes \".../Scope0\",\n\t/// \".../Scope1\" and so on, in order (<c>GameObject.DrawGizmos</c>). <c>Gizmo.Pressed.This</c> compares the path it's\n\t/// asked in, so each is tried. A hitbox a component draws inside a scope of its own isn't found.\n\t/// </summary>\n\tinternal static GameObject PressedUnselectable( Scene scene ) => PressedObject( scene, Unselectable.Has );\n\n\t/// <summary>The object the press is on, among those <paramref name=\"among\"/> accepts, or null. See <see cref=\"PressedUnselectable\"/>.</summary>\n\tinternal static GameObject PressedObject( Scene scene, System.Func<GameObject, bool> among )\n\t{\n\t\tusing var scope = Gizmo.Scope( \"hierarchyextension\" );\n\n\t\tforeach ( var go in scene.GetAllObjects( true ) )\n\t\t{\n\t\t\tif ( !among( go ) ) continue;\n\n\t\t\tvar path = $\"object-{go.GetHashCode()}\";\n\t\t\tif ( IsPressed( path ) || IsPressed( $\"{path}/Handle\" ) ) return go;\n\n\t\t\tfor ( var i = 0; i < go.Components.Count; i++ )\n\t\t\t{\n\t\t\t\tif ( IsPressed( $\"{path}/Scope{i}\" ) ) return go;\n\t\t\t}\n\t\t}\n\n\t\treturn null;\n\t}\n\n\tstatic bool IsPressed( string path )\n\t{\n\t\tGizmo.Path = path;\n\t\treturn Gizmo.Pressed.This;\n\t}\n\n\t/// <summary>\n\t/// Makes the Object Select tool's drag selections take only what's fully inside the box. The stock one takes anything\n\t/// whose bounds the box touches, and an object's bounds include everything under it (<c>ObjectEditorTool.OnBoxSelect</c>,\n\t/// <c>GameObject.GetBounds</c>), so a small box over a few decorations also took their group and the map they're in.\n\t/// It has no way in, so it runs as before and the result is redone here, from the same box:\n\t/// <list type=\"bullet\">\n\t/// <item><description>An object is taken when it's fully inside with everything under it, and then stands for all\n\t/// of that: what's under it isn't taken as well.</description></item>\n\t/// <item><description>Inside a prefab, or anything else a click selects as a whole (<see cref=\"IsSelectionBase\"/>),\n\t/// only that whole counts.</description></item>\n\t/// <item><description>Hidden and unselectable objects are never taken.</description></item>\n\t/// <item><description>Ctrl takes them out of the selection, Shift adds them, and otherwise they're the selection,\n\t/// as with the stock one (<c>EditorTool.ApplyDragSelection</c>).</description></item>\n\t/// </list>\n\t/// While the drag goes on the scene view (<see cref=\"ClickFilter.Contains\"/>) and the Hierarchy\n\t/// (<see cref=\"HierarchySelection\"/>) show the result; it goes into the selection when the mouse comes up, the frame\n\t/// the stock one finishes. Putting it in every frame would have the tool undo it every frame, and the inspector rebuild\n\t/// and the Hierarchy scroll each time, so the inspector and the move handles show the stock one's picks until then.\n\t/// </summary>\n\tstatic void WatchDragSelection( Scene scene )\n\t{\n\t\tvar camera = Gizmo.Camera;\n\n\t\tif ( Gizmo.WasLeftMousePressed && Gizmo.HasMouseFocus )\n\t\t{\n\t\t\tvar before = Unselectable.SessionOf( scene )?.Selection;\n\t\t\t_drag = before is null ? null : new DragWatch { Scene = scene, Camera = camera, Before = before.ToHashSet(), Start = Gizmo.CurrentRay, Last = Gizmo.CurrentRay };\n\t\t\treturn;\n\t\t}\n\n\t\tvar drag = _drag;\n\t\tif ( drag is null || drag.Camera != camera ) return;\n\n\t\t// the frame the mouse comes up still counts: the tool finishes its drag selection on it, from the last box. With the\n\t\t// cursor out of the view the tool leaves its box as it was, and so does this\n\t\tif ( Gizmo.HasMouseFocus && (Gizmo.IsLeftMouseDown || Gizmo.WasLeftMouseReleased) )\n\t\t{\n\t\t\tif ( Gizmo.IsLeftMouseDown ) drag.Last = Gizmo.CurrentRay;\n\t\t\tdrag.Result = IsBoxSelecting( drag, out var frustum ) ? BoxResult( scene, drag, frustum ) : null;\n\n\t\t\t// the Hierarchy shows the result as it changes, not what the stock one has put in the selection meanwhile\n\t\t\tHierarchySelection.Current?.Sync();\n\t\t}\n\n\t\t// the release, or a later frame if the release went by unseen (a code reload, say)\n\t\tif ( !Gizmo.IsLeftMouseDown ) Finish( drag );\n\t}\n\n\t/// <summary>Ends the drag: a drag selection's result goes into the selection, and the Hierarchy shows the selection again.</summary>\n\tstatic void Finish( DragWatch drag )\n\t{\n\t\tif ( _drag == drag ) _drag = null;\n\n\t\tvar result = drag.Result;\n\t\tvar selection = Unselectable.SessionOf( drag.Scene )?.Selection;\n\t\tif ( result is not null && selection is not null )\n\t\t{\n\t\t\tforeach ( var obj in selection.ToArray() )\n\t\t\t{\n\t\t\t\tif ( !result.Contains( obj ) ) selection.Remove( obj );\n\t\t\t}\n\n\t\t\tforeach ( var obj in result )\n\t\t\t{\n\t\t\t\tif ( !selection.Contains( obj ) ) selection.Add( obj );\n\t\t\t}\n\t\t}\n\n\t\tHierarchySelection.Current?.Sync();\n\t}\n\n\t/// <summary>\n\t/// Whether the Object Select tool is making a drag selection this frame, decided as it decides it\n\t/// (<c>EditorTool.UpdateBoxSelection</c>): the cursor 5 px or more from where the press began, and nothing pressed.\n\t/// Gives the box's frustum.\n\t/// </summary>\n\tstatic bool IsBoxSelecting( DragWatch drag, out Frustum frustum )\n\t{\n\t\tfrustum = default;\n\n\t\tif ( EditorToolManager.CurrentModeName != nameof( ObjectEditorTool ) ) return false;\n\t\tif ( Gizmo.Pressed.Any ) return false;\n\n\t\tvar c1 = Gizmo.Camera.ToScreen( drag.Start.Project( 100 ) );\n\t\tvar c2 = Gizmo.Camera.ToScreen( drag.Last.Project( 100 ) );\n\t\tif ( Vector2.Distance( c1, c2 ) < DragDistance ) return false;\n\n\t\tfrustum = Gizmo.Camera.GetFrustum( Rect.FromPoints( c1, c2 ) );\n\t\treturn true;\n\t}\n\n\t/// <summary>What the selection will be when the drag selection in <paramref name=\"frustum\"/> finishes.</summary>\n\tstatic HashSet<object> BoxResult( Scene scene, DragWatch drag, Frustum frustum )\n\t{\n\t\tvar taken = Take( scene, frustum, go => drag.BoundsOf( go ) );\n\t\tvar result = new HashSet<object>( drag.Before );\n\n\t\tif ( Gizmo.IsCtrlPressed )\n\t\t{\n\t\t\tresult.ExceptWith( taken );\n\t\t\treturn result;\n\t\t}\n\n\t\tif ( !Gizmo.IsShiftPressed ) result.RemoveWhere( x => x is GameObject );\n\n\t\tresult.UnionWith( taken );\n\t\treturn result;\n\t}\n\n\t/// <summary>\n\t/// The objects a drag selection in <paramref name=\"frustum\"/> takes, before Ctrl or Shift: those fully inside with\n\t/// everything under them (<paramref name=\"boundsOf\"/>, usually <see cref=\"VisibleBounds\"/>), none that are part of\n\t/// one taken, hidden or unselectable, and a prefab only whole.\n\t/// </summary>\n\tinternal static HashSet<GameObject> Take( Scene scene, Frustum frustum, System.Func<GameObject, BBox?> boundsOf )\n\t{\n\t\tvar taken = new HashSet<GameObject>();\n\n\t\tforeach ( var go in scene.GetAllObjects( true ) )\n\t\t{\n\t\t\tif ( go is Scene || go.Tags.Has( \"hidden\" ) || Unselectable.Has( go ) ) continue;\n\n\t\t\t// part of a prefab: the prefab is taken whole or not at all\n\t\t\tif ( InsideSelectionBase( go ) ) continue;\n\n\t\t\tif ( boundsOf( go ) is { } bounds && frustum.IsInside( bounds, false ) ) taken.Add( go );\n\t\t}\n\n\t\t// an object taken with everything under it stands for all of it\n\t\ttaken.RemoveWhere( go => HasAncestorIn( go, taken ) );\n\t\treturn taken;\n\t}\n\n\t/// <summary>\n\t/// What the scene view shows of <paramref name=\"go\"/> and everything under it, as one world box, or null when there's\n\t/// nothing. That's what the stock drag selection measures (<c>GameObject.GetBounds</c>: colliders, models, particles),\n\t/// plus sprites, which it leaves out, and the icon of each object that has one, as a point. A\n\t/// <c>SpriteRenderer</c> has no bounds of its own (it isn't a <c>Component.IHasBounds</c>, engine source 26.09.29),\n\t/// so with the stock drag selection no sprite can be drag-selected, and a 2D level's decorations never are.\n\t/// </summary>\n\tinternal static BBox? VisibleBounds( GameObject go )\n\t{\n\t\tBBox? result = null;\n\t\tvoid Add( BBox bounds ) => result = result is { } r ? r.AddBBox( bounds ) : bounds;\n\n\t\tforeach ( var o in go.GetAllObjects( true ) )\n\t\t{\n\t\t\tif ( o.Tags.Has( \"hidden\" ) ) continue;\n\n\t\t\tforeach ( var c in o.Components.GetAll( FindMode.EnabledInSelf ) )\n\t\t\t{\n\t\t\t\tif ( c is Component.IHasBounds hasBounds ) Add( hasBounds.LocalBounds.Transform( c.WorldTransform ) );\n\t\t\t\tif ( c is SpriteRenderer sprite ) Add( SpriteBounds( sprite ) );\n\t\t\t}\n\n\t\t\tif ( o.HasGizmoHandle ) Add( BBox.FromPositionAndSize( o.WorldPosition ) );\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/// <summary>A sprite's box in the world, worked out as its own hitbox is (<c>SpriteRenderer.DrawGizmos</c>).</summary>\n\tinternal static BBox SpriteBounds( SpriteRenderer sprite )\n\t{\n\t\tvar world = sprite.WorldTransform;\n\t\tvar billboard = sprite.Billboard is SpriteRenderer.BillboardMode.Always or SpriteRenderer.BillboardMode.YOnly;\n\n\t\tvar transform = billboard ? world.WithRotation( new Rotation() ) : world;\n\t\ttransform = transform.WithScale( new Vector3( world.Scale.x, world.Scale.x, world.Scale.z ) );\n\n\t\tvar pivot = ((sprite.CurrentAnimation?.Origin ?? new Vector2( 0.5f, 0.5f )) - 0.5f) * sprite.Size;\n\t\tvar size = new Vector2( sprite.Size.x, sprite.Size.y );\n\n\t\tif ( billboard )\n\t\t{\n\t\t\tsize += Vector2.Abs( pivot * 2 );\n\n\t\t\tvar angle = MathX.DegreeToRadian( world.Rotation.Roll() );\n\t\t\tvar cos = MathF.Abs( MathF.Cos( angle ) );\n\t\t\tvar sin = MathF.Abs( MathF.Sin( angle ) );\n\t\t\tsize = new Vector2( size.x * cos + size.y * sin, size.x * sin + size.y * cos );\n\t\t}\n\n\t\tvar local = BBox.FromPositionAndSize(\n\t\t\tbillboard ? Vector3.Zero : new Vector3( 0, pivot.x, pivot.y ),\n\t\t\tnew Vector3( billboard ? size.x : 0.5f, size.x, size.y ) );\n\n\t\treturn local.Transform( transform );\n\t}\n\n\t/// <summary>\n\t/// Whether a click selects <paramref name=\"go\"/> as a whole when it lands on something under it, as the scene view\n\t/// decides it (<c>GameObject.FindSelectionBase</c>): a network root, the outermost root of a prefab instance, or an\n\t/// object with a <see cref=\"SelectionBaseAttribute\"/> component.\n\t/// </summary>\n\tstatic bool IsSelectionBase( GameObject go )\n\t{\n\t\t// GameObject.IsOutermostPrefabInstanceRoot isn't public; the stock Hierarchy asks this\n\t\tif ( go.IsNetworkRoot || EditorUtility.Prefabs.IsOuterMostPrefabRoot( go ) ) return true;\n\n\t\treturn go.Components.GetAll().Any( x => EditorTypeLibrary.GetType( x.GetType() )?.HasAttribute<SelectionBaseAttribute>() == true );\n\t}\n\n\tstatic bool InsideSelectionBase( GameObject go )\n\t{\n\t\tfor ( var o = go.Parent; o.IsValid() && o is not Scene; o = o.Parent )\n\t\t{\n\t\t\tif ( IsSelectionBase( o ) ) return true;\n\t\t}\n\n\t\treturn false;\n\t}\n\n\tstatic bool HasAncestorIn( GameObject go, HashSet<GameObject> objects )\n\t{\n\t\tfor ( var o = go.Parent; o.IsValid() && o is not Scene; o = o.Parent )\n\t\t{\n\t\t\tif ( objects.Contains( o ) ) return true;\n\t\t}\n\n\t\treturn false;\n\t}\n\n\tvoid EditorEvent.ISceneView.ShowContextMenu( EditorEvent.ShowContextMenuEvent ev )\n\t{\n\t\tvar scene = ev.Session?.Scene;\n\t\tif ( !Unselectable.AnyIn( scene ) ) return;\n\n\t\tev.Menu.AddSeparator();\n\n\t\tif ( ev.Trace is { Hit: true } trace && Unselectable.Source( trace.GameObject ) is { } source )\n\t\t{\n\t\t\tev.Menu.AddOption( $\"Make {source.Name} Selectable\", \"touch_app\", () => Unselectable.Set( new[] { source }, false ) );\n\t\t}\n\n\t\tev.Menu.AddOption( \"Make All Selectable\", \"touch_app\", Unselectable.MakeAllSelectable );\n\t}\n\n\t/// <summary>\n\t/// A scene viewport's gizmo selection. It passes everything on to the scene's own selection, except a click on an\n\t/// unselectable object, which it holds back for <see cref=\"ResolvePendingClick\"/>.\n\t/// </summary>\n\tsealed class ClickFilter : SelectionSystem, IEnumerable<object>\n\t{\n\t\treadonly SceneViewportWidget _viewport;\n\t\treadonly SelectionSystem _selection;\n\n\t\t/// <summary>\n\t\t/// A click clears the selection just before it adds what it clicked (<c>Gizmo.Select</c>). The clear waits for\n\t\t/// the add: if the click is held back, the clear isn't wanted either.\n\t\t/// </summary>\n\t\tbool _clearing;\n\n\t\tpublic ClickFilter( SceneViewportWidget viewport, SelectionSystem selection )\n\t\t{\n\t\t\t_viewport = viewport;\n\t\t\t_selection = selection;\n\t\t}\n\n\t\tpublic override void Clear() => _clearing = true;\n\n\t\tpublic override bool Add( object obj )\n\t\t{\n\t\t\tif ( HoldBack( obj ) ) return false;\n\n\t\t\tFlush();\n\t\t\treturn _selection.Add( obj );\n\t\t}\n\n\t\tpublic override bool Remove( object obj )\n\t\t{\n\t\t\tif ( HoldBack( obj ) ) return false;\n\n\t\t\tFlush();\n\t\t\treturn _selection.Remove( obj );\n\t\t}\n\n\t\tpublic override bool Set( object obj )\n\t\t{\n\t\t\tClear();\n\t\t\treturn Add( obj );\n\t\t}\n\n\t\t/// <summary>\n\t\t/// Also what the gizmos draw as selected. While a drag selection goes on, that's what it will select when the mouse\n\t\t/// comes up (<see cref=\"WatchDragSelection\"/>), not what the stock one has put in the selection meanwhile.\n\t\t/// </summary>\n\t\tpublic override bool Contains( object obj ) => _drag?.Result is { } result ? result.Contains( obj ) : _selection.Contains( obj );\n\n\t\tpublic override bool Any() => _drag?.Result is { } result ? result.Count > 0 : _selection.Any();\n\n\t\tpublic override int GetHashCode() => _selection.GetHashCode();\n\n\t\tIEnumerator<object> IEnumerable<object>.GetEnumerator() => _selection.GetEnumerator();\n\n\t\tIEnumerator IEnumerable.GetEnumerator() => _selection.GetEnumerator();\n\n\t\t/// <summary>A clear still waiting for its add goes through.</summary>\n\t\tpublic void Flush()\n\t\t{\n\t\t\tif ( !_clearing ) return;\n\n\t\t\t_clearing = false;\n\t\t\t_selection.Clear();\n\t\t}\n\n\t\tbool HoldBack( object obj )\n\t\t{\n\t\t\tif ( obj is not GameObject go || !Unselectable.Has( go ) ) return false;\n\n\t\t\t_clearing = false;\n\t\t\t_pending = new PendingClick( _viewport, _selection, _viewport.GizmoInstance.Input );\n\t\t\treturn true;\n\t\t}\n\t}\n}\n"
},
{
"Ident": "trashpandagames.hierarchyextension",
"Path": ".obj/__compiler_extra.cs",
"FileName": "__compiler_extra.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 398910,
"IsPrivate": false,
"Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"Hierarchy Extension\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"hierarchyextension\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"trashpandagames\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"trashpandagames.hierarchyextension\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"29\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-09-30T19:33:36.0810281Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.122.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.122.0\")]"
},
{
"Ident": "trashpandagames.hierarchyextension",
"Path": "Assembly.cs",
"FileName": "Assembly.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 398910,
"IsPrivate": false,
"Code": "global using Sandbox;\nglobal using System.Collections.Generic;\nglobal using System.Linq;\n"
},
{
"Ident": "trashpandagames.hierarchyextension",
"Path": "Editor/HierarchySelection.cs",
"FileName": "HierarchySelection.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 398910,
"IsPrivate": false,
"Code": "using System.Collections.Generic;\nusing System.Linq;\n\nnamespace HierarchyExtension;\n\n/// <summary>\n/// The Hierarchy's selection, so its rows are right while a drag selection is going on in the scene view.\n/// <para>\n/// The stock drag selection puts whatever its box touches into the scene's selection on every frame of the drag, and\n/// the Hierarchy highlights exactly that: parents, maps and unselectable objects that the fully-inside rule\n/// (<see cref=\"ScenePicking\"/>) only takes out when the mouse comes up. Taking them out every frame would have the stock\n/// one put them back every frame. So the Hierarchy gets a selection of its own instead: its tree's\n/// <c>TreeView.Selection</c> is a public setting, and the tree paints rows by asking it, repaints when its hash changes,\n/// and scrolls to what's added to it (tools source, <c>BaseItemWidget</c>, 26.09.29).\n/// </para>\n/// <para>\n/// It holds a copy of what the Hierarchy should show: the scene's selection, or during a drag selection what that will\n/// select (<see cref=\"ScenePicking.DragResult\"/>). Selecting in the Hierarchy goes straight to the scene's selection,\n/// as before. The inspector and the move handles follow the scene's selection, so during a drag they still show the\n/// stock one's picks.\n/// </para>\n/// </summary>\npublic sealed class HierarchySelection : SelectionSystem\n{\n\tstatic HierarchySelection _current;\n\n\t/// <summary>The one on the Hierarchy in use, if any.</summary>\n\tinternal static HierarchySelection Current => _current;\n\n\treadonly SelectionSystem _selection;\n\n\tHierarchySelection( SelectionSystem selection )\n\t{\n\t\t_selection = selection;\n\t}\n\n\t/// <summary>\n\t/// Puts one on the Hierarchy's tree, and again whenever the tree is given the scene's selection back: it does that\n\t/// each time it rebuilds, and on a new scene. Then brings its copy up to date.\n\t/// </summary>\n\t[EditorEvent.Frame]\n\tstatic void Frame()\n\t{\n\t\tvar tree = SceneTreeWidget.Current?.TreeView;\n\t\tvar selection = SceneEditorSession.Active?.Selection;\n\t\tif ( !tree.IsValid() || selection is null ) return;\n\n\t\tif ( tree.Selection == _current && _current?._selection == selection )\n\t\t{\n\t\t\t_current.Sync();\n\t\t\treturn;\n\t\t}\n\n\t\t// the tree has the scene's own, or one of these from before a code reload that wasn't carried over\n\t\tvar ours = tree.Selection?.GetType().FullName == typeof( HierarchySelection ).FullName;\n\t\tif ( tree.Selection != selection && !ours ) return;\n\n\t\tvar view = new HierarchySelection( selection );\n\n\t\t// filled before the tree has it, so filling it doesn't scroll the tree\n\t\tview.Sync();\n\t\ttree.Selection = view;\n\t\t_current = view;\n\t}\n\n\t/// <summary>Makes the copy what the Hierarchy should show now, adding and removing only what differs.</summary>\n\tinternal void Sync()\n\t{\n\t\tIEnumerable<object> target = ScenePicking.DragResult ?? (IEnumerable<object>)_selection;\n\t\tvar wanted = target.ToHashSet();\n\n\t\tforeach ( var obj in this.ToArray() )\n\t\t{\n\t\t\tif ( !wanted.Contains( obj ) ) base.Remove( obj );\n\t\t}\n\n\t\tforeach ( var obj in target )\n\t\t{\n\t\t\tif ( !base.Contains( obj ) ) base.Add( obj );\n\t\t}\n\t}\n\n\tpublic override bool Contains( object obj ) => ScenePicking.DragResult is null ? _selection.Contains( obj ) : base.Contains( obj );\n\n\tpublic override bool Any() => ScenePicking.DragResult is null ? _selection.Any() : base.Any();\n\n\tpublic override bool Add( object obj )\n\t{\n\t\tvar added = _selection.Add( obj );\n\t\tSync();\n\t\treturn added;\n\t}\n\n\tpublic override bool Remove( object obj )\n\t{\n\t\tvar removed = _selection.Remove( obj );\n\t\tSync();\n\t\treturn removed;\n\t}\n\n\tpublic override void Clear()\n\t{\n\t\t_selection.Clear();\n\t\tSync();\n\t}\n\n\tpublic override bool Set( object obj )\n\t{\n\t\tvar changed = _selection.Set( obj );\n\t\tSync();\n\t\treturn changed;\n\t}\n}\n"
},
{
"Ident": "trashpandagames.hierarchyextension",
"Path": "Editor/SelectableColumn.cs",
"FileName": "SelectableColumn.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 398910,
"IsPrivate": false,
"Code": "using System;\nusing System.Collections.Generic;\n\nnamespace HierarchyExtension;\n\n/// <summary>\n/// The Hierarchy's column of hands, one on each row just left of where the stock Hide shows its eye. A crossed hand marks\n/// an object that can't be selected in the scene view (<see cref=\"Unselectable\"/>); it's faint when that comes from a\n/// parent. Hovering a row shows a faint hand to click. A click makes the row's object unselectable, or selectable again;\n/// on a row that's unselectable through a parent, it frees that parent.\n/// <para>\n/// The stock Hierarchy has no way in for this: <c>GameObjectNode.OnPaint</c> paints each row whole, and\n/// <c>GameObjectNode.AddGameObjectMenuItems</c> builds the row's context menu, with no event or interface to add to\n/// either (tools source, <c>Scene/SceneTree</c>, 26.09.29). So this is a narrow strip laid over the right-hand end of the\n/// Hierarchy's rows. It paints the hands and takes the clicks there; everywhere else the tree works as before.\n/// </para>\n/// </summary>\npublic sealed class SelectableColumn : Widget\n{\n\t/// <summary>An icon's box, as wide as the stock eye's.</summary>\n\tconst float IconWidth = 18;\n\n\t/// <summary>The stock eye's box and its gap: 18 wide, 4 in from the row's end (<c>GameObjectNode.OnPaint</c>).</summary>\n\tconst float EyeWidth = 4 + 18;\n\n\tstatic SelectableColumn _current;\n\n\t/// <summary>The column on the Hierarchy in use, if it's open.</summary>\n\tinternal static SelectableColumn Current => _current.IsValid() && _current._tree.IsValid() ? _current : null;\n\n\treadonly TreeView _tree;\n\n\t/// <summary>The Hierarchy's tree this column lies over.</summary>\n\tinternal TreeView Tree => _tree;\n\n\t/// <summary>A hash of what was painted, so the strip repaints only when rows move or change.</summary>\n\tint _look;\n\n\tSelectableColumn( TreeView tree ) : base( tree )\n\t{\n\t\t_tree = tree;\n\t\tMouseTracking = true;\n\t\tCursor = CursorShape.Finger;\n\n\t\tPlace();\n\t\tVisible = true;\n\t}\n\n\t/// <summary>Puts one on the Hierarchy's tree, and again whenever the dock builds a new tree.</summary>\n\t[EditorEvent.Frame]\n\tstatic void Attach()\n\t{\n\t\tvar tree = SceneTreeWidget.Current?.TreeView;\n\t\tif ( !tree.IsValid() ) return;\n\t\tif ( _current.IsValid() && _current._tree == tree ) return;\n\n\t\t_current = new SelectableColumn( tree );\n\t}\n\n\t[EditorEvent.Frame]\n\tvoid Frame()\n\t{\n\t\tif ( !_tree.IsValid() ) return;\n\n\t\tPlace();\n\n\t\tvar look = Look();\n\t\tif ( look == _look ) return;\n\n\t\t_look = look;\n\t\tUpdate();\n\t}\n\n\t/// <summary>\n\t/// Over the rows' right-hand end, just left of the eye's box, from the top down to the last row in view, so a click\n\t/// on the empty space under the rows still reaches the tree.\n\t/// </summary>\n\tvoid Place()\n\t{\n\t\tvar right = _tree.CanvasRect.Right;\n\t\tvar bottom = 0f;\n\t\tforeach ( var (row, _) in Rows() )\n\t\t{\n\t\t\tright = row.Rect.Right;\n\t\t\tbottom = row.Rect.Bottom;\n\t\t}\n\n\t\tvar position = new Vector2( right - EyeWidth - IconWidth, 0 );\n\t\tvar size = new Vector2( IconWidth, MathF.Min( bottom, _tree.Height ) );\n\n\t\tif ( Position != position ) Position = position;\n\t\tif ( Size != size ) Size = size;\n\t}\n\n\t/// <summary>The rows in view that show an object, top to bottom, in the tree's coordinates.</summary>\n\tinternal IEnumerable<(VirtualWidget Row, GameObject Object)> Rows()\n\t{\n\t\tvar x = _tree.CanvasRect.Left + 1;\n\t\tvar y = 0f;\n\n\t\twhile ( y < _tree.Height )\n\t\t{\n\t\t\tvar row = _tree.GetItemAt( new Vector2( x, y ) );\n\t\t\tif ( row is null )\n\t\t\t{\n\t\t\t\ty += 2;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif ( row.Object is TreeNode { Value: GameObject go } && go.IsValid() && go is not Scene )\n\t\t\t\tyield return (row, go);\n\n\t\t\ty = MathF.Max( y + 1, row.Rect.Bottom + 1 );\n\t\t}\n\t}\n\n\t/// <summary>The object on the row at <paramref name=\"y\"/>, in this strip's coordinates.</summary>\n\tGameObject ObjectAt( float y )\n\t{\n\t\tvar row = _tree.GetItemAt( new Vector2( _tree.CanvasRect.Left + 1, y + Position.y ) );\n\t\treturn row?.Object is TreeNode { Value: GameObject go } && go.IsValid() && go is not Scene ? go : null;\n\t}\n\n\t/// <summary>The row under the cursor, in the tree or in this strip.</summary>\n\tinternal VirtualWidget HoveredRow()\n\t{\n\t\tvar cursor = _tree.FromScreen( Editor.Application.CursorPosition );\n\t\tif ( !_tree.LocalRect.IsInside( cursor ) ) return null;\n\n\t\treturn _tree.GetItemAt( new Vector2( _tree.CanvasRect.Left + 1, cursor.y ) );\n\t}\n\n\tint Look()\n\t{\n\t\tvar hash = new HashCode();\n\t\thash.Add( Size );\n\n\t\tvar hovered = HoveredRow();\n\t\tforeach ( var (row, go) in Rows() )\n\t\t{\n\t\t\thash.Add( row.Rect.Top );\n\t\t\thash.Add( go.Id );\n\t\t\thash.Add( go.Active );\n\t\t\thash.Add( Unselectable.IsSet( go ) );\n\t\t\thash.Add( Unselectable.Has( go ) );\n\t\t\thash.Add( row == hovered );\n\t\t}\n\n\t\treturn hash.ToHashCode();\n\t}\n\n\tprotected override void OnPaint()\n\t{\n\t\tvar hovered = HoveredRow();\n\n\t\tforeach ( var (row, go) in Rows() )\n\t\t{\n\t\t\tvar hand = Unselectable.IsSet( go ) ? Hand.Own : Unselectable.Has( go ) ? Hand.FromParent : row == hovered ? Hand.Offered : Hand.None;\n\t\t\tPaintHand( new Rect( 0, row.Rect.Top - Position.y, IconWidth, row.Rect.Height ), hand, go.Active );\n\t\t}\n\t}\n\n\t/// <summary>What a row's hand shows.</summary>\n\tinternal enum Hand\n\t{\n\t\t/// <summary>Selectable, and not hovered: nothing.</summary>\n\t\tNone,\n\n\t\t/// <summary>Selectable and hovered: a faint hand to click.</summary>\n\t\tOffered,\n\n\t\t/// <summary>Unselectable through a parent: a faint crossed hand.</summary>\n\t\tFromParent,\n\n\t\t/// <summary>Unselectable by its own tag: a crossed hand.</summary>\n\t\tOwn,\n\t}\n\n\t/// <summary>Paints <paramref name=\"hand\"/> in <paramref name=\"rect\"/>, dimmed for a disabled object as its row's text is.</summary>\n\tinternal static void PaintHand( Rect rect, Hand hand, bool active )\n\t{\n\t\tif ( hand == Hand.None ) return;\n\n\t\tvar icon = hand == Hand.Offered ? \"touch_app\" : \"do_not_touch\";\n\t\tvar alpha = hand == Hand.Own ? 0.9f : 0.35f;\n\t\tif ( !active ) alpha *= 0.5f;\n\n\t\tPaint.Pen = Theme.TextControl.WithAlpha( alpha );\n\t\tPaint.DrawIcon( rect, icon, 14, TextFlag.Center );\n\t}\n\n\tprotected override void OnMousePress( MouseEvent e )\n\t{\n\t\tbase.OnMousePress( e );\n\n\t\t// taken here, so the row under the hand isn't selected as well\n\t\te.Accepted = true;\n\t\tif ( !e.LeftMouseButton ) return;\n\n\t\tvar go = ObjectAt( e.LocalPosition.y );\n\t\tif ( go is null ) return;\n\n\t\tif ( Unselectable.IsSet( go ) ) Unselectable.Set( new[] { go }, false );\n\t\telse if ( Unselectable.Source( go ) is { } source ) Unselectable.Set( new[] { source }, false );\n\t\telse Unselectable.Set( new[] { go }, true );\n\n\t\tToolTip = TooltipFor( go );\n\t\t_look = 0;\n\t\tUpdate();\n\t}\n\n\tprotected override void OnMouseMove( MouseEvent e )\n\t{\n\t\tbase.OnMouseMove( e );\n\n\t\tToolTip = TooltipFor( ObjectAt( e.LocalPosition.y ) );\n\t}\n\n\t/// <summary>The strip lies over the tree, so the wheel over it scrolls the tree, the way the tree scrolls itself.</summary>\n\tprotected override void OnMouseWheel( WheelEvent e )\n\t{\n\t\tif ( !_tree.SmoothScrolling )\n\t\t{\n\t\t\te.Ignore();\n\t\t\treturn;\n\t\t}\n\n\t\te.Accept();\n\t\t_tree.SmoothScrollTarget += e.Delta < 0 ? _tree.VerticalScrollbar.SingleStep : -_tree.VerticalScrollbar.SingleStep;\n\t}\n\n\tstatic string TooltipFor( GameObject go )\n\t{\n\t\tif ( go is null ) return null;\n\n\t\tif ( Unselectable.IsSet( go ) )\n\t\t\treturn \"Not selectable in the scene view: clicks go through it. Click to make it selectable.\";\n\n\t\tif ( Unselectable.Source( go ) is { } source )\n\t\t\treturn $\"Not selectable in the scene view, because {source.Name} isn't. Click to make {source.Name} selectable.\";\n\n\t\treturn \"Click to make it not selectable in the scene view: clicks will go through it to what's behind.\";\n\t}\n}\n"
},
{
"Ident": "trashpandagames.hierarchyextension",
"Path": "Editor/HierarchyExtensionMcpTools.cs",
"FileName": "HierarchyExtensionMcpTools.cs",
"PackageType": "library",
"CodeKind": "Editor",
"AssetVersionId": 398910,
"IsPrivate": false,
"Code": "using System;\nusing Editor.Mcp;\n\nnamespace HierarchyExtension;\n\n/// <summary>\n/// Editor bridge tools for Hierarchy Extension, so an agent driving the editor can see and set which objects are\n/// unselectable in the scene view (<see cref=\"Unselectable\"/>), try a click without making it, and see the Hierarchy's\n/// hand column, which the bridge can't otherwise see.\n/// </summary>\n[McpToolset( \"hierarchyextension\", \"Hierarchy Extension - objects unselectable in the scene view: list, set, try a click through them, draw the Hierarchy's hand column\" )]\npublic static class HierarchyExtensionMcpTools\n{\n\t/// <summary>\n\t/// The open scene's unselectable objects (each with how many objects under it that makes unselectable too), whether\n\t/// each viewport of the scene view picks through the click filter, and where the Hierarchy's hand column sits\n\t/// against the rows in view.\n\t/// </summary>\n\t[McpTool.ReadOnly( \"hierarchy_unselectable\" )]\n\tpublic static object List()\n\t{\n\t\tvar scene = SceneEditorSession.Active?.Scene ?? throw new Exception( \"No scene is open\" );\n\n\t\tvar view = SceneViewWidget.Current;\n\t\tvar column = SelectableColumn.Current;\n\t\tvar rows = column?.Rows().ToList();\n\t\tvar firstRow = rows?.Count > 0 ? rows[0].Row.Rect : (Rect?)null;\n\n\t\treturn new\n\t\t{\n\t\t\tScene = scene.Name,\n\t\t\tUnselectable = scene.GetAllObjects( false ).Where( Unselectable.IsSet ).Select( x => new\n\t\t\t{\n\t\t\t\tx.Name,\n\t\t\t\tx.Id,\n\t\t\t\tUnder = x.GetAllObjects( false ).Count() - 1,\n\t\t\t} ),\n\t\t\tViewports = view?._viewports?.Values.Select( x => new\n\t\t\t{\n\t\t\t\tx.Id,\n\t\t\t\tSelection = x.GizmoInstance?.Selection?.GetType().Name,\n\t\t\t} ),\n\t\t\tHierarchy = SceneTreeWidget.Current?.TreeView?.Selection is { } shown ? new\n\t\t\t{\n\t\t\t\tSelection = shown.GetType().Name,\n\t\t\t\tShows = shown.Select( x => x is GameObject go ? go.Name : x?.ToString() ),\n\t\t\t\tSelected = SceneEditorSession.Active?.Selection.Select( x => x is GameObject go ? go.Name : x?.ToString() ),\n\t\t\t} : null,\n\t\t\tColumn = column is null ? null : new\n\t\t\t{\n\t\t\t\tPosition = column.Position,\n\t\t\t\tSize = column.Size,\n\t\t\t\tVisible = column.Visible,\n\t\t\t\tTree = column.Tree.Size,\n\t\t\t\tCanvas = column.Tree.CanvasRect,\n\t\t\t\tFirstRow = firstRow,\n\t\t\t\tStockEye = firstRow is { } r ? new Rect( r.Right - 22, r.Top, 18, r.Height ) : (Rect?)null,\n\t\t\t\tRowsInView = rows.Count,\n\t\t\t},\n\t\t};\n\t}\n\n\t/// <summary>\n\t/// Make objects unselectable in the scene view, or selectable again: one undo step, and the scene counts as changed,\n\t/// as with the Hierarchy's hands. Objects under an unselectable one stay unselectable until it's made selectable.\n\t/// </summary>\n\t/// <param name=\"ids\">Object ids, comma separated, from scene_tree or find_game_objects.</param>\n\t/// <param name=\"selectable\">True to make them selectable again, false to make them unselectable.</param>\n\t[McpTool( \"hierarchy_set_selectable\" )]\n\tpublic static object SetSelectable( string ids, bool selectable )\n\t{\n\t\tvar objects = Find( ids );\n\t\tUnselectable.Set( objects, !selectable );\n\n\t\treturn objects.Select( x => new { x.Name, x.Id, Unselectable = Unselectable.Has( x ), Own = Unselectable.IsSet( x ) } );\n\t}\n\n\t/// <summary>\n\t/// What a click at a world point in the scene view would select with some objects held out, worked out the way the\n\t/// scene view does it for real (<see cref=\"ClickThrough\"/>). Nothing is clicked: the selection and the scene stay as\n\t/// they are. Uses the viewport last clicked in, and its camera, so the point should be in its view.\n\t/// </summary>\n\t/// <param name=\"point\">The world point to click, \"x,y,z\".</param>\n\t/// <param name=\"holdOut\">Ids to hold out instead of the unselectable objects, comma separated. Empty holds out nothing.</param>\n\t[McpTool.ReadOnly( \"hierarchy_click_through\" )]\n\tpublic static object ClickAt( string point, string holdOut = null )\n\t{\n\t\tvar (session, gizmos, input, world) = CursorAt( point );\n\n\t\tIReadOnlyList<GameObject> heldOut = holdOut is null\n\t\t\t? session.Scene.GetAllObjects( true ).Where( Unselectable.IsSet ).ToArray()\n\t\t\t: Find( holdOut );\n\n\t\tvar changes = ClickThrough.Run( session.Scene, gizmos.Settings, input, session.Selection, heldOut );\n\n\t\treturn new\n\t\t{\n\t\t\tPoint = world,\n\t\t\tHeldOut = heldOut.Select( x => x.Name ),\n\t\t\tChanges = changes.Select( x => new\n\t\t\t{\n\t\t\t\tChange = x.Change.ToString(),\n\t\t\t\tTarget = x.Target is GameObject go ? $\"{go.Name} ({go.Id})\" : x.Target?.ToString(),\n\t\t\t} ),\n\t\t};\n\t}\n\n\t/// <summary>\n\t/// What a press at a world point in the scene view lands on, as its gizmos see it: whether anything is pressed (which\n\t/// stops a drag from starting a drag selection), which object it's found on, and whether that object is unselectable,\n\t/// in which case a drag from there lets go of the press. Nothing is pressed for real. Uses the viewport last clicked\n\t/// in, and its camera, so the point should be in its view.\n\t/// </summary>\n\t/// <param name=\"point\">The world point to press, \"x,y,z\".</param>\n\t[McpTool.ReadOnly( \"hierarchy_press_probe\" )]\n\tpublic static object PressProbe( string point )\n\t{\n\t\tvar (session, gizmos, input, world) = CursorAt( point );\n\n\t\tvar (pressedAnything, pressed) = ClickThrough.WhilePressed( session.Scene, gizmos.Settings, input,\n\t\t\t() => (Gizmo.Pressed.Any, ScenePicking.PressedObject( session.Scene, _ => true )) );\n\n\t\treturn new\n\t\t{\n\t\t\tPoint = world,\n\t\t\tPressedAnything = pressedAnything,\n\t\t\tPressedObject = pressed is null ? null : $\"{pressed.Name} ({pressed.Id})\",\n\t\t\tUnselectable = pressed is not null && Unselectable.Has( pressed ),\n\t\t};\n\t}\n\n\t/// <summary>\n\t/// What a drag selection between two world points would take, by Hierarchy Extension's rule (fully inside, a prefab\n\t/// only whole, never what's under a taken object, never unselectable ones), next to what the stock rule takes (any\n\t/// object whose bounds, with everything under it, the box touches). Selects nothing. Uses the viewport last clicked\n\t/// in, and its camera, so both points should be in its view.\n\t/// </summary>\n\t/// <param name=\"from\">One corner of the box, a world point \"x,y,z\".</param>\n\t/// <param name=\"to\">The opposite corner.</param>\n\t[McpTool.ReadOnly( \"hierarchy_box_probe\" )]\n\tpublic static object BoxProbe( string from, string to )\n\t{\n\t\tvar (session, gizmos, _, _) = CursorAt( from );\n\t\tvar camera = gizmos.Input.Camera;\n\t\tvar scene = session.Scene;\n\n\t\tvar box = Rect.FromPoints( camera.ToScreen( Vector3.Parse( from ) ), camera.ToScreen( Vector3.Parse( to ) ) );\n\t\tvar frustum = camera.GetFrustum( box );\n\n\t\tvar bounds = new Dictionary<GameObject, BBox>();\n\t\tBBox BoundsOf( GameObject go )\n\t\t{\n\t\t\tif ( !bounds.TryGetValue( go, out var b ) ) bounds[go] = b = go.GetBounds();\n\t\t\treturn b;\n\t\t}\n\n\t\t// ObjectEditorTool.OnBoxSelect\n\t\tvar stock = scene.GetAllObjects( true )\n\t\t\t.Where( go => go is not Scene && !go.Tags.Has( \"hidden\" ) )\n\t\t\t.Where( go => !(BoundsOf( go ).Size.AlmostEqual( 0.0f ) && !go.HasGizmoHandle) && frustum.IsInside( BoundsOf( go ), true ) )\n\t\t\t.Select( x => x.Name );\n\n\t\treturn new\n\t\t{\n\t\t\tScreenBox = box,\n\t\t\tTakes = ScenePicking.Take( scene, frustum, ScenePicking.VisibleBounds ).Select( x => x.Name ),\n\t\t\tStockTakes = stock,\n\t\t};\n\t}\n\n\t/// <summary>The session, gizmo instance and cursor input for pointing at <paramref name=\"point\"/> in the scene view.</summary>\n\tstatic (SceneEditorSession Session, Gizmo.Instance Gizmos, Gizmo.Inputs Input, Vector3 World) CursorAt( string point )\n\t{\n\t\tvar view = SceneViewWidget.Current ?? throw new Exception( \"No scene view is open\" );\n\t\tvar viewport = view.LastSelectedViewportWidget ?? view._viewports?.Values.FirstOrDefault() ?? throw new Exception( \"The scene view has no viewport\" );\n\t\tvar gizmos = viewport.GizmoInstance;\n\t\tvar camera = gizmos.Input.Camera ?? throw new Exception( \"The viewport hasn't drawn yet\" );\n\n\t\tvar world = Vector3.Parse( point );\n\t\tvar forward = camera.Rotation.Forward;\n\n\t\tvar input = gizmos.Input;\n\t\tinput.CursorRay = camera.Ortho\n\t\t\t? new Ray( world - forward * Vector3.Dot( world - camera.Position, forward ), forward )\n\t\t\t: new Ray( camera.Position, (world - camera.Position).Normal );\n\t\tinput.CursorPosition = camera.ToScreen( world );\n\t\tinput.Modifiers = KeyboardModifiers.None;\n\t\tinput.LeftMouse = false;\n\n\t\treturn (view.Session, gizmos, input, world);\n\t}\n\n\t/// <summary>\n\t/// Draws the Hierarchy's rows in view into a PNG as the dock paints them (not the expand arrows), with the hand\n\t/// column over them. The bridge can't see editor windows, so this is how to check the column lines up. The preview\n\t/// ids draw as if they were unselectable, and the hovered one with its faint hand, without changing anything.\n\t/// </summary>\n\t/// <param name=\"path\">Where to write the PNG, an absolute path.</param>\n\t/// <param name=\"preview\">Ids to draw as unselectable, comma separated, as if their hands had been clicked.</param>\n\t/// <param name=\"hovered\">An id to draw as the row under the cursor.</param>\n\t[McpTool.ReadOnly( \"hierarchy_render_rows\" )]\n\tpublic static object RenderRows( string path, string preview = null, string hovered = null )\n\t{\n\t\tvar previewed = preview is null ? new HashSet<GameObject>() : Find( preview ).ToHashSet();\n\t\tvar hoveredObject = hovered is null ? null : Find( hovered ).FirstOrDefault();\n\n\t\tSelectableColumn.Hand HandOf( GameObject go )\n\t\t{\n\t\t\tif ( Unselectable.IsSet( go ) || previewed.Contains( go ) ) return SelectableColumn.Hand.Own;\n\n\t\t\tfor ( var o = go.Parent; o.IsValid() && o is not Scene; o = o.Parent )\n\t\t\t{\n\t\t\t\tif ( Unselectable.IsSet( o ) || previewed.Contains( o ) ) return SelectableColumn.Hand.FromParent;\n\t\t\t}\n\n\t\t\treturn go == hoveredObject ? SelectableColumn.Hand.Offered : SelectableColumn.Hand.None;\n\t\t}\n\n\t\tvar column = SelectableColumn.Current ?? throw new Exception( \"The Hierarchy isn't open\" );\n\t\tvar tree = column.Tree;\n\t\tvar rows = column.Rows().ToList();\n\t\tif ( rows.Count == 0 ) throw new Exception( \"No rows in view\" );\n\n\t\tvar top = rows.Min( x => x.Row.Rect.Top );\n\t\tvar bottom = rows.Max( x => x.Row.Rect.Bottom );\n\t\tvar pixmap = new Pixmap( (int)tree.Width, (int)MathF.Ceiling( bottom - top ) );\n\n\t\tusing ( Paint.ToPixmap( pixmap ) )\n\t\t{\n\t\t\tPaint.Translate( new Vector2( 0, -top ) );\n\n\t\t\tPaint.ClearPen();\n\t\t\tPaint.SetBrush( Theme.ControlBackground );\n\t\t\tPaint.DrawRect( new Rect( 0, top, tree.Width, bottom - top ) );\n\n\t\t\tforeach ( var (row, go) in rows )\n\t\t\t{\n\t\t\t\tif ( row.Object is not TreeNode node ) continue;\n\n\t\t\t\t// as TreeView.PaintItem shifts a row by its depth before the node paints it\n\t\t\t\tvar indent = tree.IndentWidth * row.Column + tree.ExpandWidth;\n\t\t\t\trow.Rect.Left += indent;\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tnode.OnPaint( row );\n\t\t\t\t}\n\t\t\t\tfinally\n\t\t\t\t{\n\t\t\t\t\trow.Rect.Left -= indent;\n\t\t\t\t}\n\n\t\t\t\tSelectableColumn.PaintHand( new Rect( column.Position.x, row.Rect.Top, column.Size.x, row.Rect.Height ), HandOf( go ), go.Active );\n\t\t\t}\n\t\t}\n\n\t\tpixmap.SavePng( path );\n\n\t\treturn new { Path = path, Size = new Vector2( pixmap.Width, pixmap.Height ), Rows = rows.Select( x => x.Object.Name ) };\n\t}\n\n\tstatic GameObject[] Find( string ids )\n\t{\n\t\tvar scene = SceneEditorSession.Active?.Scene ?? throw new Exception( \"No scene is open\" );\n\n\t\tvar found = new List<GameObject>();\n\t\tforeach ( var id in (ids ?? \"\").Split( ',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries ) )\n\t\t{\n\t\t\tvar go = Guid.TryParse( id, out var guid ) ? scene.Directory.FindByGuid( guid ) : null;\n\t\t\tfound.Add( go ?? throw new Exception( $\"No object with id {id}\" ) );\n\t\t}\n\n\t\treturn found.ToArray();\n\t}\n}\n"
},
{
"Ident": "trashpandagames.hierarchyextension",
"Path": "UnitTests/UnitTest.cs",
"FileName": "UnitTest.cs",
"PackageType": "library",
"CodeKind": "UnitTest",
"AssetVersionId": 398910,
"IsPrivate": false,
"Code": "global using Microsoft.VisualStudio.TestTools.UnitTesting;\r\n\r\n[TestClass]\r\npublic class TestInit\r\n{\r\n\tpublic static Sandbox.TestAppSystem AppSystem;\r\n\r\n\t[AssemblyInitialize]\r\n\tpublic static void AssemblyInitialize( TestContext context )\r\n\t{\r\n\t\tAppSystem = new Sandbox.TestAppSystem();\r\n\t\tAppSystem.Init();\r\n\t}\r\n\r\n\t[AssemblyCleanup]\r\n\tpublic static void AssemblyCleanup()\r\n\t{\r\n\t\tAppSystem.Shutdown();\r\n\t}\r\n}\r\n"
},
{
"Ident": "trashpandagames.hierarchyextension",
"Path": "Code/Assembly.cs",
"FileName": "Assembly.cs",
"PackageType": "library",
"CodeKind": "Game",
"AssetVersionId": 398910,
"IsPrivate": false,
"Code": "global using Sandbox;\nglobal using System.Collections.Generic;\nglobal using System.Linq;\n"
}
]
}