Editor/Tools/ExtraTools.cs

Editor tools for scene editing. Implements MCP (editor) commands to add/remove tags, get bounds, rotate GameObjects to look at a target, create many GameObjects in a pattern, and copy component property values between objects.

File Access
using System;
using System.Linq;
using Editor;
using Sandbox;
using SboxMcp.Registry;
using static SboxMcp.Tools.ToolHelpers;

namespace SboxMcp.Tools;

/// <summary>
/// High-value concrete tools on top of the universal mechanisms: tags,
/// bounds, orientation, bulk creation, component copy.
/// </summary>
public static class ExtraTools
{
	[McpTool( "gameobject_add_tag", "Adds a tag to a GameObject (tags drive collision filtering, queries and gameplay logic).", ToolCategory.GameObject, Writes = true )]
	public static object AddTag( [Desc( "GameObject id or unique name" )] string gameObject, string tag )
	{
		var session = RequireSession();
		var go = FindGameObject( gameObject );

		using var undo = session.UndoScope( $"MCP: add tag {tag}" ).WithGameObjectChanges( go, GameObjectUndoFlags.Properties ).Push();
		go.Tags.Add( tag );

		return new { gameObject = go.Name, tags = go.Tags.TryGetAll().ToArray() };
	}

	[McpTool( "gameobject_remove_tag", "Removes a tag from a GameObject.", ToolCategory.GameObject, Writes = true )]
	public static object RemoveTag( [Desc( "GameObject id or unique name" )] string gameObject, string tag )
	{
		var session = RequireSession();
		var go = FindGameObject( gameObject );

		using var undo = session.UndoScope( $"MCP: remove tag {tag}" ).WithGameObjectChanges( go, GameObjectUndoFlags.Properties ).Push();
		go.Tags.Remove( tag );

		return new { gameObject = go.Name, tags = go.Tags.TryGetAll().ToArray() };
	}

	[McpTool( "gameobject_get_bounds", "Gets a GameObject's world-space bounding box (renderers + children).", ToolCategory.GameObject )]
	public static object GetBounds( [Desc( "GameObject id or unique name" )] string gameObject )
	{
		var go = FindGameObject( gameObject );
		var b = go.GetBounds();

		return new
		{
			gameObject = go.Name,
			center = V( b.Center ),
			size = V( b.Size ),
			mins = V( b.Mins ),
			maxs = V( b.Maxs )
		};
	}

	[McpTool( "gameobject_look_at", "Rotates a GameObject to face a target position or another GameObject.", ToolCategory.GameObject, Writes = true )]
	public static object LookAt(
		[Desc( "GameObject id or unique name to rotate" )] string gameObject,
		[Desc( "Target world position [x, y, z]; ignored when targetObject is set" )] float[] position = null,
		[Desc( "Target GameObject id/name to face" )] string targetObject = null )
	{
		var session = RequireSession();
		var go = FindGameObject( gameObject );

		var target = targetObject is not null
			? FindGameObject( targetObject ).WorldPosition
			: position is not null ? ToVector3( position, "position" )
			: throw new ArgumentException( "Pass either position or targetObject" );

		using var undo = session.UndoScope( "MCP: look at" ).WithGameObjectChanges( go, GameObjectUndoFlags.Properties ).Push();
		go.WorldRotation = Rotation.LookAt( (target - go.WorldPosition).Normal );

		return new { gameObject = go.Name, rotation = A( go.WorldRotation ) };
	}

	[McpTool( "gameobject_create_many", "Creates several GameObjects at once (e.g. a grid or row). Returns their ids.", ToolCategory.GameObject, Writes = true )]
	public static object CreateMany(
		[Desc( "Base name; each gets a numeric suffix" )] string name,
		[Desc( "How many to create" )] int count,
		[Desc( "Position of the first [x, y, z]" )] float[] startPosition = null,
		[Desc( "Offset added per object [x, y, z]" )] float[] step = null,
		[Desc( "Parent id; omit for scene root" )] string parentId = null )
	{
		if ( count is < 1 or > 512 )
			throw new ArgumentException( "count must be 1..512" );

		var session = RequireSession();
		var parent = parentId is null ? null : FindGameObject( parentId );
		var start = startPosition is null ? Vector3.Zero : ToVector3( startPosition, "startPosition" );
		var delta = step is null ? new Vector3( 60, 0, 0 ) : ToVector3( step, "step" );

		using var undo = session.UndoScope( $"MCP: create {count} objects" ).WithGameObjectCreations().Push();

		var created = new object[count];
		for ( var i = 0; i < count; i++ )
		{
			var go = session.Scene.CreateObject();
			go.Name = $"{name} {i + 1}";
			if ( parent is not null ) go.Parent = parent;
			go.WorldPosition = start + delta * i;
			created[i] = new { id = go.Id, name = go.Name };
		}

		return new { count, created };
	}

	[McpTool( "component_copy", "Copies all property values from one component to another GameObject's component of the same type (e.g. clone a configured renderer's settings). Creates the component on the target if it doesn't have one yet.", ToolCategory.Component, Writes = true )]
	public static object CopyComponent(
		[Desc( "Source GameObject id or unique name" )] string fromGameObject,
		[Desc( "Target GameObject id or unique name" )] string toGameObject,
		[Desc( "Component type name" )] string type )
	{
		var session = RequireSession();
		var source = FindComponent( FindGameObject( fromGameObject ), type );
		var toGo = FindGameObject( toGameObject );

		var existing = toGo.Components.GetAll<Component>( FindMode.EverythingInSelf )
			.FirstOrDefault( c => c.GetType() == source.GetType() );

		using var undo = session.UndoScope( $"MCP: copy {type}" )
			.WithComponentCreations()
			.WithComponentChanges( existing is not null ? new[] { existing } : Array.Empty<Component>() )
			.Push();

		// create a matching component on the target if it has none yet
		var target = existing ?? toGo.Components.Create( FindComponentType( type ) )
			?? throw new InvalidOperationException( $"Could not create a {type} on '{toGameObject}'" );

		if ( source.Serialize() is System.Text.Json.Nodes.JsonObject node )
		{
			// keep the target's own identity; copy only the values
			node.Remove( "__guid" );
			target.DeserializeImmediately( node );
		}

		return new { copied = type, from = fromGameObject, to = toGameObject, createdTarget = existing is null };
	}
}