Editor/HierarchyExtensionMcpTools.cs
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();
}
}