Editor/Drivers/OrbitSnapshotDriver.cs

Editor utility that captures a screenshot orbiting a target scene object. It finds the target by GUID or name, computes an orbit camera position from pitch/yaw/radius (or presets), spawns a temporary camera in the active scene, asks the editor runtime to take a screenshot, writes a PNG into .sbox/captures, then returns metadata about the capture.

File AccessExternal Download
#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using Sandbox;

namespace Editor.Mcp
{
	internal static class OrbitSnapshotDriver
	{
		private static Scene? ResolveScene()
		{
			if ( Game.ActiveScene != null ) return Game.ActiveScene;
			var session = EditorRuntime.GetStaticProperty( "Editor.SceneEditorSession", "Active" );
			if ( session != null )
			{
				return EditorRuntime.GetMember( session, "Scene" ) as Scene;
			}
			return null;
		}

		public static ToolOutcome<object> CaptureOrbit(
			string target,
			float? radius = null,
			float pitch = 25f,
			float yaw = 45f,
			string? preset = null,
			int resolution = 1024,
			string? fileName = null,
			bool includeUi = false )
		{
			var scene = ResolveScene();
			if ( scene == null )
			{
				return ToolOutcome<object>.Failure( null, ToolStatus.Unavailable, "orbit.no_scene", "No active scene loaded." );
			}

			var go = scene.Directory.FindByGuid( Guid.TryParse( target, out var guid ) ? guid : Guid.Empty )
				?? scene.GetAllObjects( false ).FirstOrDefault( o => string.Equals( o.Name, target, StringComparison.OrdinalIgnoreCase ) );

			if ( go == null )
			{
				return ToolOutcome<object>.Failure( null, ToolStatus.Failed, "orbit.not_found", $"Target '{target}' not found." );
			}

			// Compute target center and bounding radius
			var center = go.WorldPosition;
			var modelRenderer = go.GetComponent<ModelRenderer>();
			var boundsRadius = 100f;
			if ( modelRenderer != null && modelRenderer.Model != null )
			{
				var modelBounds = modelRenderer.Model.Bounds;
				center = go.WorldPosition + modelBounds.Center;
				boundsRadius = Math.Max( modelBounds.Size.Length * 0.75f, 60f );
			}

			var actualRadius = (radius.HasValue && radius.Value > 0) ? radius.Value : boundsRadius * 1.5f;
			var actualPitch = pitch;
			var actualYaw = yaw;

			if ( !string.IsNullOrWhiteSpace( preset ) )
			{
				switch ( preset.ToLowerInvariant().Trim() )
				{
					case "front": actualPitch = 0f; actualYaw = 0f; break;
					case "back": actualPitch = 0f; actualYaw = 180f; break;
					case "left": actualPitch = 0f; actualYaw = 90f; break;
					case "right": actualPitch = 0f; actualYaw = -90f; break;
					case "top": actualPitch = 88f; actualYaw = 0f; break;
					case "isometric": actualPitch = 30f; actualYaw = 45f; break;
					case "cinematic": actualPitch = 15f; actualYaw = 35f; break;
				}
			}

			var pitchRad = (float)(actualPitch * Math.PI / 180.0);
			var yawRad = (float)(actualYaw * Math.PI / 180.0);
			var offsetX = actualRadius * (float)Math.Cos( pitchRad ) * (float)Math.Sin( yawRad );
			var offsetY = actualRadius * (float)Math.Cos( pitchRad ) * (float)Math.Cos( yawRad );
			var offsetZ = actualRadius * (float)Math.Sin( pitchRad );

			var camPos = center + new Vector3( offsetX, offsetY, offsetZ );
			var camRot = Rotation.LookAt( center - camPos );

			// Spawn temporary camera attached directly to active scene
			var camGo = scene.CreateObject( true );
			camGo.Name = "__temporary_orbit_camera__";
			camGo.WorldPosition = camPos;
			camGo.WorldRotation = camRot;
			var cam = camGo.Components.Create<CameraComponent>();

			var boundedRes = Math.Clamp( resolution, 128, 4096 );
			var safeName = go.Name.Replace( " ", "_" ).ToLowerInvariant();
			var outFileName = string.IsNullOrWhiteSpace( fileName ) ? $"orbit_{safeName}_{actualPitch:0}_{actualYaw:0}.png" : fileName.Trim();
			var artifactDir = Path.Combine( ".sbox", "captures" );
			Directory.CreateDirectory( artifactDir );
			var artifactPath = Path.Combine( artifactDir, outFileName );

			object? screenshotResult = null;
			byte[]? imageBytes = null;
			string? invokeError = null;

			try
			{
				screenshotResult = EditorRuntime.InvokeStatic( "Editor.Mcp.SceneTools", "CameraScreenshot", cam.Id.ToString(), boundedRes, boundedRes, includeUi );

				if ( screenshotResult is Sandbox.Bitmap bmp )
				{
					imageBytes = bmp.ToPng();
					File.WriteAllBytes( artifactPath, imageBytes );
				}
				else if ( screenshotResult != null )
				{
					var json = JsonSerializer.Serialize( screenshotResult );
					var node = JsonNode.Parse( json );
					var base64Data = (node as JsonArray)?[0]?["data"]?.GetValue<string>() ?? (node as JsonObject)?["data"]?.GetValue<string>();
					if ( !string.IsNullOrEmpty( base64Data ) )
					{
						imageBytes = Convert.FromBase64String( base64Data );
						File.WriteAllBytes( artifactPath, imageBytes );
					}
				}

			}
			catch ( Exception ex )
			{
				invokeError = ex.ToString();
			}
			finally
			{
				camGo.Destroy();
			}

			return ToolOutcome<object>.Ready( new
			{
				Target = go.Name,
				Id = go.Id.ToString(),
				TargetCenter = center,
				CameraPosition = camPos,
				CameraAngles = camRot.Angles(),
				Radius = actualRadius,
				Pitch = actualPitch,
				Yaw = actualYaw,
				Preset = preset,
				Resolution = boundedRes,
				ArtifactPath = artifactPath,
				Captured = imageBytes != null,
				FileSizeBytes = imageBytes?.Length ?? 0,
				RawResultType = screenshotResult?.GetType().FullName,
			} );
		}
	}
}