Editor-side service and bridge handlers for capturing camera images and managing bookmarks. Saves session-scoped camera bookmarks, renders perspective and orthographic images from temporary cameras, compares captures to session baselines using SkiaSharp, writes temporary PNGs, and exposes JSON/IPC handlers for save/list/delete/capture_set/capture_topdown.
using Editor;
using Sandbox;
using SkiaSharp;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
// Camera capture workflow shared by the native inline-image tools and the legacy
// file-IPC handlers. Bookmarks and comparison baselines are deliberately scoped
// to this editor session: no project files are written and hotload/restart clears
// the state.
internal sealed class CameraBookmarkDefinition
{
internal string Name { get; init; }
internal string Source { get; init; }
internal string TargetId { get; init; }
internal string TargetName { get; init; }
internal Vector3 Position { get; init; }
internal Rotation Rotation { get; init; }
internal Vector3? LookAt { get; init; }
internal float FieldOfView { get; init; }
internal DateTime SavedUtc { get; init; }
}
internal readonly record struct CameraBaselineKey( string Name, int Width, int Height, bool RenderUI );
internal sealed class CameraDiffMetrics
{
internal bool BaselineAvailable { get; init; }
internal bool Comparable { get; init; }
internal bool? ExactMatch { get; init; }
internal int Threshold { get; init; }
internal double? MeanAbsoluteChannelDelta { get; init; }
internal double? ChangedPixelPercent { get; init; }
internal int? MaxChannelDelta { get; init; }
internal string Reason { get; init; }
}
internal sealed class CameraOcclusionReport
{
internal bool Checked { get; init; }
internal bool Blocked { get; init; }
internal bool StartedSolid { get; init; }
internal string BlockerId { get; init; }
internal string BlockerName { get; init; }
internal float? HitDistance { get; init; }
internal float? CameraToLookAtDistance { get; init; }
internal string Reason { get; init; }
}
internal sealed class CameraCaptureEntry
{
internal CameraBookmarkDefinition Bookmark { get; init; }
internal byte[] Png { get; init; }
internal CameraDiffMetrics Diff { get; init; }
internal CameraOcclusionReport Occlusion { get; init; }
}
internal sealed class CameraCaptureSetResult
{
internal int Width { get; init; }
internal int Height { get; init; }
internal bool RenderUI { get; init; }
internal bool ComparedPrevious { get; init; }
internal int DiffThreshold { get; init; }
internal IReadOnlyList<CameraCaptureEntry> Captures { get; init; }
}
internal sealed class CameraTopdownResult
{
internal byte[] Png { get; init; }
internal string TargetId { get; init; }
internal string TargetName { get; init; }
internal Vector3 Center { get; init; }
internal Vector3 CameraPosition { get; init; }
internal Rotation CameraRotation { get; init; }
internal float CameraHeight { get; init; }
internal float WorldHeight { get; init; }
internal float WorldWidth { get; init; }
internal float GroundPlaneZ { get; init; }
internal int Width { get; init; }
internal int Height { get; init; }
internal bool RenderUI { get; init; }
}
internal static class CameraCaptureService
{
internal const int MaxBookmarks = 64;
internal const int MaxCaptureSetViews = 8;
internal const int MaxCaptureWidth = 1600;
internal const int MaxCaptureHeight = 1200;
internal const long MaxCaptureSetPixels = 8_000_000;
private const int MaxBaselines = 64;
private const long MaxBaselineBytes = 64L * 1024L * 1024L;
private const float OcclusionEndTolerance = 8f;
private static readonly object StateLock = new();
private static readonly Dictionary<string, CameraBookmarkDefinition> Bookmarks =
new( StringComparer.OrdinalIgnoreCase );
private static readonly Dictionary<CameraBaselineKey, byte[]> Baselines = new();
private static readonly Queue<CameraBaselineKey> BaselineOrder = new();
private static long BaselineBytes;
internal static CameraBookmarkDefinition SaveBookmark(
string name,
string targetId,
Vector3? position,
Vector3? lookAt,
Rotation? rotation,
float? fov )
{
var scene = RequireScene();
name = NormalizeName( name );
if ( !string.IsNullOrWhiteSpace( targetId ) && position.HasValue )
throw new Exception( "Pass targetId OR position, not both." );
if ( lookAt.HasValue && rotation.HasValue )
throw new Exception( "Pass lookAt OR rotation, not both." );
if ( !position.HasValue && (lookAt.HasValue || rotation.HasValue) )
throw new Exception( "lookAt/rotation require position." );
CameraBookmarkDefinition bookmark;
if ( !string.IsNullOrWhiteSpace( targetId ) )
{
var target = FindTarget( scene, targetId );
float resolvedFov = ClampFov( fov ?? 80f );
var pose = FrameTarget( target, resolvedFov );
bookmark = new CameraBookmarkDefinition
{
Name = name,
Source = "target",
TargetId = target.Id.ToString(),
TargetName = target.Name,
Position = pose.Position,
Rotation = pose.Rotation,
LookAt = pose.LookAt,
FieldOfView = resolvedFov,
SavedUtc = DateTime.UtcNow,
};
}
else if ( position.HasValue )
{
var resolvedRotation = lookAt.HasValue
? LookAtRotation( position.Value, lookAt.Value )
: rotation ?? Rotation.Identity;
bookmark = new CameraBookmarkDefinition
{
Name = name,
Source = "explicit",
Position = position.Value,
Rotation = resolvedRotation,
LookAt = lookAt,
FieldOfView = ClampFov( fov ?? 80f ),
SavedUtc = DateTime.UtcNow,
};
}
else
{
var camera = VisualHelpers.FindMainCamera( scene )
?? throw new Exception( "No main camera in the scene. Pass targetId or position." );
bookmark = new CameraBookmarkDefinition
{
Name = name,
Source = "main-camera",
Position = camera.GameObject.WorldPosition,
Rotation = camera.GameObject.WorldRotation,
FieldOfView = ClampFov( fov ?? camera.FieldOfView ),
SavedUtc = DateTime.UtcNow,
};
}
lock ( StateLock )
{
if ( !Bookmarks.ContainsKey( name ) && Bookmarks.Count >= MaxBookmarks )
throw new Exception( $"Camera bookmark limit reached ({MaxBookmarks}). Delete one before saving another." );
Bookmarks[name] = bookmark;
RemoveBaselinesForBookmarkLocked( name );
}
return bookmark;
}
internal static IReadOnlyList<CameraBookmarkDefinition> ListBookmarks()
{
lock ( StateLock )
{
return Bookmarks.Values
.OrderBy( x => x.Name, StringComparer.OrdinalIgnoreCase )
.ToArray();
}
}
internal static bool DeleteBookmark( string name )
{
name = NormalizeName( name );
lock ( StateLock )
{
var deleted = Bookmarks.Remove( name );
if ( deleted )
RemoveBaselinesForBookmarkLocked( name );
return deleted;
}
}
internal static CameraCaptureSetResult CaptureSet(
IReadOnlyList<string> requestedNames,
int width,
int height,
bool renderUI,
bool comparePrevious,
int diffThreshold,
bool checkOcclusion )
{
if ( requestedNames == null || requestedNames.Count == 0 )
throw new Exception( "names must contain at least one saved camera bookmark." );
if ( requestedNames.Count > MaxCaptureSetViews )
throw new Exception( $"capture_camera_set accepts at most {MaxCaptureSetViews} bookmarks." );
width = Math.Clamp( width, 16, MaxCaptureWidth );
height = Math.Clamp( height, 16, MaxCaptureHeight );
diffThreshold = Math.Clamp( diffThreshold, 0, 255 );
long aggregatePixels = (long)requestedNames.Count * width * height;
if ( aggregatePixels > MaxCaptureSetPixels )
{
throw new Exception(
$"Capture set is {aggregatePixels:N0} pixels; maximum is {MaxCaptureSetPixels:N0}. " +
"Reduce width/height or capture fewer bookmarks." );
}
var normalizedNames = requestedNames.Select( NormalizeName ).ToArray();
var duplicate = normalizedNames
.GroupBy( x => x, StringComparer.OrdinalIgnoreCase )
.FirstOrDefault( x => x.Count() > 1 );
if ( duplicate != null )
throw new Exception( $"Duplicate bookmark in capture set: {duplicate.Key}" );
CameraBookmarkDefinition[] bookmarks;
lock ( StateLock )
{
bookmarks = normalizedNames.Select( name =>
{
if ( !Bookmarks.TryGetValue( name, out var bookmark ) )
throw new Exception( $"Camera bookmark not found: {name}. Call list_camera_bookmarks." );
return bookmark;
} ).ToArray();
}
var baselineKeys = bookmarks
.Select( bookmark => BaselineKey( bookmark.Name, width, height, renderUI ) )
.ToArray();
var previousBaselines = new byte[bookmarks.Length][];
lock ( StateLock )
{
for ( int i = 0; i < baselineKeys.Length; i++ )
Baselines.TryGetValue( baselineKeys[i], out previousBaselines[i] );
}
var scene = RequireScene();
var captures = new List<CameraCaptureEntry>( bookmarks.Length );
for ( int index = 0; index < bookmarks.Length; index++ )
{
var bookmark = bookmarks[index];
var occlusion = checkOcclusion
? CheckOcclusion( scene, bookmark )
: new CameraOcclusionReport { Checked = false, Reason = "checkOcclusion=false" };
float? farPlane = BookmarkFarPlane( scene, bookmark );
var png = RenderPerspective(
scene,
bookmark.Position,
bookmark.Rotation,
bookmark.FieldOfView,
farPlane,
width,
height,
renderUI );
var diff = comparePrevious
? ComparePng( previousBaselines[index], png, diffThreshold )
: null;
captures.Add( new CameraCaptureEntry
{
Bookmark = bookmark,
Png = png,
Diff = diff,
Occlusion = occlusion,
} );
}
lock ( StateLock )
{
for ( int i = 0; i < bookmarks.Length; i++ )
{
if ( !Bookmarks.TryGetValue( bookmarks[i].Name, out var current )
|| !ReferenceEquals( current, bookmarks[i] ) )
throw new Exception( $"Camera bookmark changed during capture: {bookmarks[i].Name}. Capture again." );
}
for ( int i = 0; i < captures.Count; i++ )
StoreBaselineLocked( baselineKeys[i], captures[i].Png );
}
return new CameraCaptureSetResult
{
Width = width,
Height = height,
RenderUI = renderUI,
ComparedPrevious = comparePrevious,
DiffThreshold = diffThreshold,
Captures = captures,
};
}
internal static CameraTopdownResult CaptureTopdown(
string targetId,
Vector3? explicitCenter,
float? requestedWorldHeight,
float? requestedCameraHeight,
int width,
int height,
bool renderUI )
{
if ( !string.IsNullOrWhiteSpace( targetId ) && explicitCenter.HasValue )
throw new Exception( "Pass targetId OR center, not both." );
if ( string.IsNullOrWhiteSpace( targetId ) && !explicitCenter.HasValue )
throw new Exception( "capture_topdown requires targetId or center." );
if ( requestedWorldHeight.HasValue && !float.IsFinite( requestedWorldHeight.Value ) )
throw new Exception( "worldHeight must be finite." );
if ( requestedCameraHeight.HasValue && !float.IsFinite( requestedCameraHeight.Value ) )
throw new Exception( "cameraHeight must be finite." );
width = Math.Clamp( width, 16, MaxCaptureWidth );
height = Math.Clamp( height, 16, MaxCaptureHeight );
float aspect = (float)width / height;
var scene = RequireScene();
string resolvedTargetId = null;
string targetName = null;
Vector3 center;
float groundPlaneZ;
float boundsBottomZ;
float boundsTopZ;
float defaultWorldHeight;
if ( !string.IsNullOrWhiteSpace( targetId ) )
{
var target = FindTarget( scene, targetId );
var bounds = target.GetBounds();
center = bounds.Center;
groundPlaneZ = bounds.Mins.z;
boundsBottomZ = bounds.Mins.z;
boundsTopZ = bounds.Maxs.z;
defaultWorldHeight = Math.Max(
Math.Max( bounds.Size.x, bounds.Size.y / aspect ) * 1.25f,
64f );
resolvedTargetId = target.Id.ToString();
targetName = target.Name;
}
else
{
center = explicitCenter.Value;
groundPlaneZ = center.z;
boundsBottomZ = center.z;
boundsTopZ = center.z;
defaultWorldHeight = 1024f;
}
float worldHeight = Math.Clamp( requestedWorldHeight ?? defaultWorldHeight, 16f, 100_000f );
float worldWidth = worldHeight * aspect;
float minimumCameraHeight = Math.Clamp(
Math.Max( boundsTopZ - center.z + 64f, 64f ),
64f,
1_000_000f );
float defaultCameraHeight = Math.Max( worldHeight * 2f, minimumCameraHeight + 448f );
float cameraHeight = Math.Clamp(
Math.Max( requestedCameraHeight ?? defaultCameraHeight, minimumCameraHeight ),
minimumCameraHeight,
1_000_000f );
var cameraPosition = center + Vector3.Up * cameraHeight;
float farPlane = Math.Max( cameraPosition.z - boundsBottomZ + 64f, 1024f );
var cameraRotation = Rotation.LookAt( Vector3.Down, Vector3.Forward );
var png = RenderOrthographic(
scene,
cameraPosition,
cameraRotation,
worldHeight,
farPlane,
width,
height,
renderUI );
return new CameraTopdownResult
{
Png = png,
TargetId = resolvedTargetId,
TargetName = targetName,
Center = center,
CameraPosition = cameraPosition,
CameraRotation = cameraRotation,
CameraHeight = cameraHeight,
WorldHeight = worldHeight,
WorldWidth = worldWidth,
GroundPlaneZ = groundPlaneZ,
Width = width,
Height = height,
RenderUI = renderUI,
};
}
internal static Vector3 ParseVector3String( string value, string label )
{
if ( string.IsNullOrWhiteSpace( value ) )
throw new Exception( $"{label} must be a comma vector \"x,y,z\"." );
var parts = value.Split( ',', StringSplitOptions.TrimEntries );
if ( parts.Length != 3
|| !float.TryParse( parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var x )
|| !float.TryParse( parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out var y )
|| !float.TryParse( parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var z )
|| !float.IsFinite( x ) || !float.IsFinite( y ) || !float.IsFinite( z ) )
{
throw new Exception( $"{label} must be a finite comma vector \"x,y,z\", got: {value}" );
}
return new Vector3( x, y, z );
}
internal static Vector3 ParseVector3Element( JsonElement element, string label )
{
if ( element.ValueKind == JsonValueKind.String )
{
// Native MCP gate stringifies structured args - recover {"x":..}/[x,y,z]
// passed as a JSON string (same contract as OptionalRotation below).
if ( ClaudeBridge.TryUnwrapStructuredJsonString( element, out var structuredVec ) )
return ParseVector3Element( structuredVec, label );
return ParseVector3String( element.GetString(), label );
}
if ( element.ValueKind == JsonValueKind.Object )
{
return new Vector3(
RequiredFiniteComponent( element, "x", label ),
RequiredFiniteComponent( element, "y", label ),
RequiredFiniteComponent( element, "z", label ) );
}
if ( element.ValueKind == JsonValueKind.Array && element.GetArrayLength() == 3 )
{
var values = element.EnumerateArray().ToArray();
return new Vector3(
RequiredFiniteNumber( values[0], $"{label}[0]" ),
RequiredFiniteNumber( values[1], $"{label}[1]" ),
RequiredFiniteNumber( values[2], $"{label}[2]" ) );
}
throw new Exception( $"{label} must be a finite {{x,y,z}} object, three-number array, or \"x,y,z\" string." );
}
private static float RequiredFiniteComponent( JsonElement value, string property, string label )
{
if ( !value.TryGetProperty( property, out var element ) )
throw new Exception( $"{label}.{property} is required." );
return RequiredFiniteNumber( element, $"{label}.{property}" );
}
private static float RequiredFiniteNumber( JsonElement value, string label )
{
if ( value.ValueKind != JsonValueKind.Number || !value.TryGetSingle( out var result ) || !float.IsFinite( result ) )
throw new Exception( $"{label} must be a finite number." );
return result;
}
internal static object BookmarkManifest( CameraBookmarkDefinition bookmark )
{
var angles = bookmark.Rotation.Angles();
return new
{
name = bookmark.Name,
scope = "editor-session",
source = bookmark.Source,
targetId = bookmark.TargetId,
targetName = bookmark.TargetName,
position = VectorManifest( bookmark.Position ),
rotation = new
{
pitch = (double)angles.pitch,
yaw = (double)angles.yaw,
roll = (double)angles.roll,
},
lookAt = bookmark.LookAt.HasValue ? VectorManifest( bookmark.LookAt.Value ) : null,
fov = (double)bookmark.FieldOfView,
savedUtc = bookmark.SavedUtc.ToString( "O", CultureInfo.InvariantCulture ),
};
}
internal static object CaptureManifest( CameraCaptureSetResult result )
{
return new
{
captured = true,
scope = "editor-session",
count = result.Captures.Count,
width = result.Width,
height = result.Height,
renderUI = result.RenderUI,
comparePrevious = result.ComparedPrevious,
diffThreshold = result.DiffThreshold,
baselineNote = "The latest capture becomes the session baseline for the same bookmark, dimensions, and renderUI setting, subject to a 64-entry/64 MiB session budget.",
images = result.Captures.Select( (capture, index) => new
{
index,
name = capture.Bookmark.Name,
bookmark = BookmarkManifest( capture.Bookmark ),
occlusion = OcclusionManifest( capture.Occlusion ),
diff = DiffManifest( capture.Diff ),
} ).ToArray(),
};
}
internal static object TopdownManifest( CameraTopdownResult result )
{
float worldUnitsPerPixel = result.WorldHeight / result.Height;
float halfX = result.WorldHeight * 0.5f;
float halfY = result.WorldWidth * 0.5f;
var angles = result.CameraRotation.Angles();
return new
{
captured = true,
projection = "orthographic-topdown",
targetId = result.TargetId,
targetName = result.TargetName,
width = result.Width,
height = result.Height,
renderUI = result.RenderUI,
center = VectorManifest( result.Center ),
camera = new
{
position = VectorManifest( result.CameraPosition ),
rotation = new
{
pitch = (double)angles.pitch,
yaw = (double)angles.yaw,
roll = (double)angles.roll,
},
height = (double)result.CameraHeight,
screenUpWorld = VectorManifest( result.CameraRotation.Up ),
screenRightWorld = VectorManifest( result.CameraRotation.Right ),
},
groundPlane = new
{
z = (double)result.GroundPlaneZ,
worldHeight = (double)result.WorldHeight,
worldWidth = (double)result.WorldWidth,
worldUnitsPerPixel = (double)worldUnitsPerPixel,
min = VectorManifest( new Vector3(
result.Center.x - halfX,
result.Center.y - halfY,
result.GroundPlaneZ ) ),
max = VectorManifest( new Vector3(
result.Center.x + halfX,
result.Center.y + halfY,
result.GroundPlaneZ ) ),
},
determinism = "Fixed orthographic projection; screen-up is world +X; renderUI defaults false.",
};
}
internal static string WriteTemporaryPng( byte[] png, string prefix )
{
var safePrefix = string.IsNullOrWhiteSpace( prefix ) ? "camera" : prefix;
var path = Path.Combine(
Path.GetTempPath(),
$"bridge_{safePrefix}_{Guid.NewGuid():N}.png" );
File.WriteAllBytes( path, png );
return path;
}
private static Scene RequireScene()
{
return (Game.IsPlaying ? Game.ActiveScene : SceneEditorSession.Active?.Scene)
?? throw new Exception( "No active scene" );
}
private static GameObject FindTarget( Scene scene, string targetId )
{
if ( !Guid.TryParse( targetId, out _ ) )
throw new Exception( $"targetId must be a GameObject GUID, got: {targetId}" );
return ClaudeBridge.ResolveGameObject( scene, targetId )
?? throw new Exception( $"GameObject not found: {targetId}" );
}
private static (Vector3 Position, Rotation Rotation, Vector3 LookAt) FrameTarget( GameObject target, float fieldOfView )
{
var bounds = target.GetBounds();
var center = bounds.Center;
float radius = bounds.Size.Length * 0.5f;
if ( radius < 0.5f ) radius = 64f;
float halfFovRadians = Math.Clamp( fieldOfView, 1f, 179f ) * (MathF.PI / 360f);
float distance = Math.Max( radius / MathF.Tan( halfFovRadians ) * 1.2f, 150f );
var position = center + new Vector3( -1f, -0.6f, 0.7f ).Normal * distance;
return (position, LookAtRotation( position, center ), center);
}
internal static Rotation LookAtRotation( Vector3 position, Vector3 lookAt )
{
var direction = lookAt - position;
if ( direction.Length < 0.001f )
throw new Exception( "Camera position and lookAt cannot be the same point." );
var forward = direction.Normal;
var up = MathF.Abs( forward.z ) > 0.999f ? Vector3.Forward : Vector3.Up;
return Rotation.LookAt( forward, up );
}
internal static float ClampFov( float fov )
{
if ( !float.IsFinite( fov ) ) throw new Exception( "fov must be finite." );
return Math.Clamp( fov, 1f, 179f );
}
internal static float FramingFarPlane( float cameraToCenter, float radius )
{
if ( !float.IsFinite( cameraToCenter ) || cameraToCenter < 0f
|| !float.IsFinite( radius ) || radius < 0f )
throw new Exception( "Camera framing distances must be finite and non-negative." );
return Math.Clamp(
Math.Max( cameraToCenter + radius * 2f + 256f, 10_000f ),
10_000f,
10_000_000f );
}
private static float? BookmarkFarPlane( Scene scene, CameraBookmarkDefinition bookmark )
{
if ( !bookmark.LookAt.HasValue ) return null;
float radius = 0f;
if ( !string.IsNullOrWhiteSpace( bookmark.TargetId ) )
{
var target = ClaudeBridge.ResolveGameObject( scene, bookmark.TargetId );
if ( target != null )
{
var bounds = target.GetBounds();
if ( BoundsInspection.IsFinite( bounds.Size ) )
radius = Math.Max( bounds.Size.Length * 0.5f, 0f );
}
}
return FramingFarPlane(
Vector3.DistanceBetween( bookmark.Position, bookmark.LookAt.Value ),
radius );
}
private static byte[] RenderPerspective(
Scene scene,
Vector3 position,
Rotation rotation,
float fov,
float? farPlane,
int width,
int height,
bool renderUI )
{
return Render(
scene,
position,
rotation,
width,
height,
renderUI,
camera =>
{
camera.FieldOfView = ClampFov( fov );
if ( farPlane.HasValue ) camera.ZFar = farPlane.Value;
} );
}
private static byte[] RenderOrthographic(
Scene scene,
Vector3 position,
Rotation rotation,
float worldHeight,
float farPlane,
int width,
int height,
bool renderUI )
{
return Render(
scene,
position,
rotation,
width,
height,
renderUI,
camera =>
{
camera.Orthographic = true;
camera.OrthographicHeight = worldHeight;
camera.ZNear = 1f;
camera.ZFar = Math.Max( farPlane, 1024f );
} );
}
private static byte[] Render(
Scene scene,
Vector3 position,
Rotation rotation,
int width,
int height,
bool renderUI,
Action<CameraComponent> configure )
{
GameObject tempCamera = null;
try
{
tempCamera = scene.CreateObject( false );
tempCamera.Name = "__bridge_capture_cam";
tempCamera.WorldPosition = position;
tempCamera.WorldRotation = rotation;
var camera = tempCamera.AddComponent<CameraComponent>();
camera.IsMainCamera = false;
camera.Priority = -100;
configure?.Invoke( camera );
tempCamera.Enabled = true;
using var bitmap = new Bitmap( width, height );
camera.RenderToBitmap( bitmap, renderUI );
return bitmap.ToPng();
}
finally
{
tempCamera?.Destroy();
}
}
private static CameraOcclusionReport CheckOcclusion(
Scene scene,
CameraBookmarkDefinition bookmark )
{
if ( !bookmark.LookAt.HasValue )
{
return new CameraOcclusionReport
{
Checked = false,
Reason = "Bookmark uses rotation only; no lookAt line is available.",
};
}
var lookAt = bookmark.LookAt.Value;
float totalDistance = Vector3.DistanceBetween( bookmark.Position, lookAt );
GameObject target = null;
if ( !string.IsNullOrWhiteSpace( bookmark.TargetId )
&& Guid.TryParse( bookmark.TargetId, out var targetGuid ) )
{
target = ClaudeBridge.ResolveGameObject( scene, bookmark.TargetId );
}
var trace = target != null
? scene.Trace.Ray( bookmark.Position, lookAt ).IgnoreGameObjectHierarchy( target ).Run()
: scene.Trace.Ray( bookmark.Position, lookAt ).Run();
bool blocked = trace.StartedSolid
|| (trace.Hit && trace.Distance < totalDistance - OcclusionEndTolerance);
return new CameraOcclusionReport
{
Checked = true,
Blocked = blocked,
StartedSolid = trace.StartedSolid,
BlockerId = blocked ? trace.GameObject?.Id.ToString() : null,
BlockerName = blocked ? trace.GameObject?.Name : null,
HitDistance = trace.Hit ? trace.Distance : null,
CameraToLookAtDistance = totalDistance,
Reason = blocked
? "A physics trace found geometry between the camera and lookAt point."
: "No blocking physics geometry was found before the lookAt point.",
};
}
private static CameraDiffMetrics ComparePng(
byte[] previousPng,
byte[] currentPng,
int threshold )
{
if ( previousPng == null || previousPng.Length == 0 )
{
return new CameraDiffMetrics
{
BaselineAvailable = false,
Comparable = false,
Threshold = threshold,
Reason = "No previous session capture exists for this bookmark and settings.",
};
}
if ( previousPng.AsSpan().SequenceEqual( currentPng ) )
{
return new CameraDiffMetrics
{
BaselineAvailable = true,
Comparable = true,
ExactMatch = true,
Threshold = threshold,
MeanAbsoluteChannelDelta = 0d,
ChangedPixelPercent = 0d,
MaxChannelDelta = 0,
Reason = "Encoded PNG bytes match exactly; pixel decode was skipped.",
};
}
try
{
using var previous = SKBitmap.Decode( previousPng );
using var current = SKBitmap.Decode( currentPng );
if ( previous == null || current == null )
{
return new CameraDiffMetrics
{
BaselineAvailable = true,
Comparable = false,
Threshold = threshold,
Reason = "SkiaSharp could not decode one of the PNG captures.",
};
}
if ( previous.Width != current.Width || previous.Height != current.Height )
{
return new CameraDiffMetrics
{
BaselineAvailable = true,
Comparable = false,
Threshold = threshold,
Reason = $"Image dimensions differ ({previous.Width}x{previous.Height} vs {current.Width}x{current.Height}).",
};
}
var previousPixels = previous.Pixels;
var currentPixels = current.Pixels;
if ( previousPixels.Length != currentPixels.Length )
throw new Exception( "Decoded pixel counts differ." );
long absoluteDelta = 0;
long changedPixels = 0;
int maxChannelDelta = 0;
long pixelCount = currentPixels.LongLength;
for ( int i = 0; i < currentPixels.Length; i++ )
{
var before = previousPixels[i];
var after = currentPixels[i];
int dr = Math.Abs( before.Red - after.Red );
int dg = Math.Abs( before.Green - after.Green );
int db = Math.Abs( before.Blue - after.Blue );
int da = Math.Abs( before.Alpha - after.Alpha );
int pixelMax = Math.Max( Math.Max( dr, dg ), Math.Max( db, da ) );
absoluteDelta += dr + dg + db + da;
if ( pixelMax > threshold ) changedPixels++;
if ( pixelMax > maxChannelDelta ) maxChannelDelta = pixelMax;
}
return new CameraDiffMetrics
{
BaselineAvailable = true,
Comparable = true,
ExactMatch = maxChannelDelta == 0,
Threshold = threshold,
MeanAbsoluteChannelDelta = pixelCount == 0
? 0d
: absoluteDelta / (pixelCount * 4d),
ChangedPixelPercent = pixelCount == 0
? 0d
: changedPixels * 100d / pixelCount,
MaxChannelDelta = maxChannelDelta,
Reason = "RGBA pixels compared with SkiaSharp; changedPixelPercent counts pixels whose largest channel delta exceeds threshold.",
};
}
catch ( Exception ex )
{
return new CameraDiffMetrics
{
BaselineAvailable = true,
Comparable = false,
Threshold = threshold,
Reason = $"PNG comparison failed: {ex.Message}",
};
}
}
private static object DiffManifest( CameraDiffMetrics diff )
{
if ( diff == null ) return null;
return new
{
baselineAvailable = diff.BaselineAvailable,
comparable = diff.Comparable,
exactMatch = diff.ExactMatch,
threshold = diff.Threshold,
meanAbsoluteChannelDelta = diff.MeanAbsoluteChannelDelta,
changedPixelPercent = diff.ChangedPixelPercent,
maxChannelDelta = diff.MaxChannelDelta,
reason = diff.Reason,
};
}
private static object OcclusionManifest( CameraOcclusionReport report )
{
if ( report == null ) return null;
return new
{
@checked = report.Checked,
blocked = report.Blocked,
startedSolid = report.StartedSolid,
blockerId = report.BlockerId,
blockerName = report.BlockerName,
hitDistance = report.HitDistance,
cameraToLookAtDistance = report.CameraToLookAtDistance,
reason = report.Reason,
};
}
private static object VectorManifest( Vector3 value ) => new
{
x = (double)value.x,
y = (double)value.y,
z = (double)value.z,
};
private static string NormalizeName( string name )
{
name = name?.Trim();
if ( string.IsNullOrWhiteSpace( name ) )
throw new Exception( "Camera bookmark name is required." );
if ( name.Length > 64 )
throw new Exception( "Camera bookmark name must be 64 characters or fewer." );
return name;
}
private static CameraBaselineKey BaselineKey( string name, int width, int height, bool renderUI )
=> new( name.ToLowerInvariant(), width, height, renderUI );
private static void StoreBaselineLocked( CameraBaselineKey key, byte[] png )
{
if ( Baselines.TryGetValue( key, out var previous ) )
BaselineBytes -= previous.LongLength;
else
BaselineOrder.Enqueue( key );
Baselines[key] = png;
BaselineBytes += png.LongLength;
while ( (Baselines.Count > MaxBaselines || BaselineBytes > MaxBaselineBytes)
&& BaselineOrder.Count > 0 )
{
var oldest = BaselineOrder.Dequeue();
if ( Baselines.TryGetValue( oldest, out var removed ) )
{
Baselines.Remove( oldest );
BaselineBytes -= removed.LongLength;
}
}
}
private static void RemoveBaselinesForBookmarkLocked( string name )
{
foreach ( var key in Baselines.Keys
.Where( key => key.Name.Equals( name, StringComparison.OrdinalIgnoreCase ) )
.ToArray() )
{
if ( Baselines.TryGetValue( key, out var removed ) )
{
Baselines.Remove( key );
BaselineBytes -= removed.LongLength;
}
}
int queued = BaselineOrder.Count;
for ( int i = 0; i < queued; i++ )
{
var key = BaselineOrder.Dequeue();
if ( Baselines.ContainsKey( key ) ) BaselineOrder.Enqueue( key );
}
}
}
public class SaveCameraBookmarkHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
string name = RequiredString( p, "name" );
string targetId = OptionalString( p, "targetId" );
Vector3? position = OptionalVector( p, "position" );
Vector3? lookAt = OptionalVector( p, "lookAt" );
Rotation? rotation = OptionalRotation( p, "rotation" );
float? fov = p.TryGetProperty( "fov", out var fovElement )
&& fovElement.ValueKind == JsonValueKind.Number
? fovElement.GetSingle()
: null;
var bookmark = CameraCaptureService.SaveBookmark(
name,
targetId,
position,
lookAt,
rotation,
fov );
return Task.FromResult<object>( new
{
saved = true,
scope = "editor-session",
bookmark = CameraCaptureService.BookmarkManifest( bookmark ),
note = "Bookmarks and comparison baselines reset on addon hotload or editor restart.",
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"save_camera_bookmark failed: {ex.Message}" } );
}
}
internal static string RequiredString( JsonElement p, string key )
{
var value = OptionalString( p, key );
if ( string.IsNullOrWhiteSpace( value ) )
throw new Exception( $"{key} is required." );
return value;
}
internal static string OptionalString( JsonElement p, string key )
{
return p.TryGetProperty( key, out var element ) && element.ValueKind == JsonValueKind.String
? element.GetString()
: null;
}
internal static Vector3? OptionalVector( JsonElement p, string key )
{
return p.TryGetProperty( key, out var element ) && element.ValueKind != JsonValueKind.Null
? CameraCaptureService.ParseVector3Element( element, key )
: null;
}
internal static Rotation? OptionalRotation( JsonElement p, string key )
{
if ( !p.TryGetProperty( key, out var element ) || element.ValueKind == JsonValueKind.Null )
return null;
if ( ClaudeBridge.TryUnwrapStructuredJsonString( element, out var structured ) )
element = structured;
float pitch;
float yaw;
float roll;
if ( element.ValueKind == JsonValueKind.String )
{
var angles = CameraCaptureService.ParseVector3String( element.GetString(), key );
(pitch, yaw, roll) = (angles.x, angles.y, angles.z);
}
else if ( element.ValueKind == JsonValueKind.Object )
{
pitch = RequiredNumber( element, "pitch", key );
yaw = RequiredNumber( element, "yaw", key );
roll = RequiredNumber( element, "roll", key );
}
else if ( element.ValueKind == JsonValueKind.Array && element.GetArrayLength() == 3 )
{
var values = element.EnumerateArray().ToArray();
if ( values.Any( value => value.ValueKind != JsonValueKind.Number
|| !value.TryGetSingle( out var number )
|| !float.IsFinite( number ) ) )
throw new Exception( $"{key} array values must be finite numbers." );
pitch = values[0].GetSingle();
yaw = values[1].GetSingle();
roll = values[2].GetSingle();
}
else
{
throw new Exception( $"{key} must be \"pitch,yaw,roll\", a numeric {{pitch,yaw,roll}} object, or a three-number array." );
}
return Rotation.From( pitch, yaw, roll );
}
private static float RequiredNumber( JsonElement value, string property, string label )
{
if ( !value.TryGetProperty( property, out var element )
|| element.ValueKind != JsonValueKind.Number
|| !element.TryGetSingle( out var result )
|| !float.IsFinite( result ) )
throw new Exception( $"{label}.{property} is required and must be a finite number." );
return result;
}
}
public class ListCameraBookmarksHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
var bookmarks = CameraCaptureService.ListBookmarks();
return Task.FromResult<object>( new
{
scope = "editor-session",
total = bookmarks.Count,
max = CameraCaptureService.MaxBookmarks,
bookmarks = bookmarks.Select( CameraCaptureService.BookmarkManifest ).ToArray(),
note = "Session-only: bookmarks reset on addon hotload or editor restart.",
} );
}
}
public class DeleteCameraBookmarkHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
string name = SaveCameraBookmarkHandler.RequiredString( p, "name" );
bool deleted = CameraCaptureService.DeleteBookmark( name );
return Task.FromResult<object>( new
{
deleted,
name,
scope = "editor-session",
note = deleted
? "Bookmark and its comparison baselines were removed."
: "Bookmark was not present; no state changed.",
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"delete_camera_bookmark failed: {ex.Message}" } );
}
}
}
public class CaptureCameraSetHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
if ( !p.TryGetProperty( "names", out var namesElement )
|| namesElement.ValueKind != JsonValueKind.Array )
{
return Task.FromResult<object>( new { error = "names (array of camera bookmark names) is required" } );
}
var nameElements = namesElement.EnumerateArray().ToArray();
if ( nameElements.Any( element => element.ValueKind != JsonValueKind.String ) )
return Task.FromResult<object>( new { error = "every names entry must be a string" } );
var names = nameElements.Select( element => element.GetString() ).ToArray();
int width = OptionalInt( p, "width", 960 );
int height = OptionalInt( p, "height", 540 );
bool renderUI = OptionalBool( p, "renderUI", false );
bool comparePrevious = OptionalBool( p, "comparePrevious", false );
int diffThreshold = OptionalInt( p, "diffThreshold", 8 );
bool checkOcclusion = OptionalBool( p, "checkOcclusion", true );
var result = CameraCaptureService.CaptureSet(
names,
width,
height,
renderUI,
comparePrevious,
diffThreshold,
checkOcclusion );
var manifest = CameraCaptureService.CaptureManifest( result );
var temporaryPaths = new List<string>( result.Captures.Count );
try
{
var captures = new List<object>( result.Captures.Count );
for ( int index = 0; index < result.Captures.Count; index++ )
{
var capture = result.Captures[index];
var path = CameraCaptureService.WriteTemporaryPng( capture.Png, "camera_set" );
temporaryPaths.Add( path );
captures.Add( new { index, name = capture.Bookmark.Name, path } );
}
return Task.FromResult<object>( new
{
captured = true,
manifest,
captures = captures.ToArray(),
note = "Legacy file-IPC result. Native MCP returns the same manifest followed by ordered inline PNG images.",
} );
}
catch
{
foreach ( var path in temporaryPaths )
{
try { File.Delete( path ); } catch { }
}
throw;
}
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"capture_camera_set failed: {ex.Message}" } );
}
}
internal static int OptionalInt( JsonElement p, string key, int fallback )
=> p.TryGetProperty( key, out var element ) && element.TryGetInt32( out var value )
? value
: fallback;
internal static bool OptionalBool( JsonElement p, string key, bool fallback )
=> p.TryGetProperty( key, out var element )
&& (element.ValueKind == JsonValueKind.True || element.ValueKind == JsonValueKind.False)
? element.GetBoolean()
: fallback;
}
public class CaptureTopdownHandler : IBridgeHandler
{
public Task<object> Execute( JsonElement p )
{
try
{
string targetId = SaveCameraBookmarkHandler.OptionalString( p, "targetId" );
Vector3? center = SaveCameraBookmarkHandler.OptionalVector( p, "center" );
float? worldHeight = OptionalFloat( p, "worldHeight" );
float? cameraHeight = OptionalFloat( p, "cameraHeight" );
int width = CaptureCameraSetHandler.OptionalInt( p, "width", 1024 );
int height = CaptureCameraSetHandler.OptionalInt( p, "height", 1024 );
bool renderUI = CaptureCameraSetHandler.OptionalBool( p, "renderUI", false );
var result = CameraCaptureService.CaptureTopdown(
targetId,
center,
worldHeight,
cameraHeight,
width,
height,
renderUI );
return Task.FromResult<object>( new
{
captured = true,
manifest = CameraCaptureService.TopdownManifest( result ),
path = CameraCaptureService.WriteTemporaryPng( result.Png, "topdown" ),
note = "Legacy file-IPC result. Native MCP returns the manifest plus an inline PNG image.",
} );
}
catch ( Exception ex )
{
return Task.FromResult<object>( new { error = $"capture_topdown failed: {ex.Message}" } );
}
}
private static float? OptionalFloat( JsonElement p, string key )
=> p.TryGetProperty( key, out var element ) && element.ValueKind == JsonValueKind.Number
? element.GetSingle()
: null;
}