#if UNITY_6000_7_OR_NEWER
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using Newtonsoft.Json;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.UIElements;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Runtime.Commands
{
///
/// Shared, runtime-safe helpers behind the visual-element capture commands
/// (capture_editor_element and capture_runtime_element). Lives in the runtime
/// assembly with no UnityEditor dependency so both the Editor command and the
/// player-side runtime command can use it.
///
/// Capture uses (runtime
/// module) and encodes the PNG here, rather than the editor-only
/// VisualElementCaptureEditorExtensions.CaptureToPNG, so the same path works in a Player.
///
/// Element selection maps a small USS-like selector string onto UQuery's public
/// (the engine's USS string parser is internal). Supported:
/// descendant (space) and child (>) combinators; each simple part is
/// Type#name.class1.class2 (any subset); optional pseudo-state suffixes
/// :checked :hover :focus :active :enabled :disabled and :not(<state>).
///
public static class VisualElementCaptureSupport
{
///
/// Returns a failure response when no GPU is available (batchmode/headless), where a capture
/// would read back a blank image; otherwise returns null (capture may proceed).
///
public static CaptureElementResponse GpuUnavailableResponse()
{
if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null)
return CaptureElementResponse.Fail("No GPU available (batchmode/headless); cannot capture.");
return null;
}
///
/// Resolve the first under matching
/// , or null if the selector is empty/invalid or nothing matches.
///
public static VisualElement ResolveElement(VisualElement root, string selector)
{
if (root == null || string.IsNullOrWhiteSpace(selector))
return null;
if (!TryBuildQuery(root, selector, out var query))
return null;
return query.First();
}
///
/// Resolve the selector against each root in order, returning the first match (and the root
/// it was found under via ), or null if none match.
///
public static VisualElement ResolveElement(IEnumerable roots, string selector,
out VisualElement matchedRoot)
{
matchedRoot = null;
if (roots == null)
return null;
foreach (var root in roots)
{
var element = ResolveElement(root, selector);
if (element != null)
{
matchedRoot = root;
return element;
}
}
return null;
}
///
/// Capture to PNG bytes at its own pixel size. Restores the active
/// RenderTexture and releases all temporaries so the capture leaves no global render state.
/// Throws for camera-drawn (world-space) or detached
/// panels (surfaced from ).
///
public static byte[] CaptureElementToPng(VisualElement element, out int width, out int height)
{
if (element == null)
throw new ArgumentNullException(nameof(element));
var rt = element.CaptureToRenderTexture();
var prevActive = RenderTexture.active;
Texture2D tex = null;
try
{
width = rt.width;
height = rt.height;
RenderTexture.active = rt;
tex = new Texture2D(rt.width, rt.height, TextureFormat.RGBA32, false);
tex.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0, false);
tex.Apply(false, false);
return tex.EncodeToPNG();
}
finally
{
RenderTexture.active = prevActive;
rt.Release();
DestroyObject(rt);
if (tex != null)
DestroyObject(tex);
}
}
///
/// Resolve the output path. An explicit rooted path is used as-is; an explicit relative path
/// is resolved against ; an empty path produces a
/// timestamped <prefix>_yyyyMMdd_HHmmss_fff.png file under
/// .
///
public static string ResolveOutputPath(string output, string relativeBaseDir, string defaultDir, string prefix)
{
if (!string.IsNullOrWhiteSpace(output))
{
return Path.IsPathRooted(output)
? output
: Path.GetFullPath(Path.Combine(relativeBaseDir, output));
}
var stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff", CultureInfo.InvariantCulture);
return Path.Combine(defaultDir, $"{prefix}_{stamp}.png");
}
/// Write PNG bytes to , creating the directory if needed.
public static void WritePng(string path, byte[] png)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
File.WriteAllBytes(path, png);
}
///
/// Capture the resolved , write the PNG to disk, and build a
/// populated success response (or a failure response if the panel cannot be captured). This
/// is the shared tail both commands run once they have an element + a panel root.
///
public static CaptureElementResponse CaptureAndRespond(VisualElement element, string selector,
string source, string output, string relativeBaseDir, string defaultDir, string prefix)
{
var gpuGuard = GpuUnavailableResponse();
if (gpuGuard != null)
return gpuGuard;
try
{
var png = CaptureElementToPng(element, out var width, out var height);
var path = ResolveOutputPath(output, relativeBaseDir, defaultDir, prefix);
WritePng(path, png);
return new CaptureElementResponse
{
Success = true,
Selector = selector,
Source = source,
Path = path,
Width = width,
Height = height,
Encoding = "png",
Base64 = Convert.ToBase64String(png),
Bytes = png.Length,
Message = $"Captured {source} to {path}"
};
}
catch (InvalidOperationException ex)
{
// The capture API throws this for camera-drawn (world-space) or detached panels.
return CaptureElementResponse.Fail(ex.Message);
}
catch (Exception ex)
{
return CaptureElementResponse.Fail($"Failed to capture '{selector}' from {source}: {ex.Message}");
}
}
// ---- Selector parsing -------------------------------------------------------------------
// Build a UQuery from the selector, mapping tokens onto the public UQueryBuilder fluent API.
static bool TryBuildQuery(VisualElement root, string selector, out UQueryBuilder query)
{
query = root.Query();
var parts = Tokenize(selector);
if (parts.Count == 0)
return false;
for (int i = 0; i < parts.Count; i++)
{
var (isChild, partStr) = parts[i];
if (!TryParsePart(partStr, out var part))
return false;
if (i == 0)
query = query.Name(part.Name);
else
query = isChild
? query.Children(part.Name)
: query.Descendents(part.Name);
foreach (var cls in part.Classes)
query = query.Class(cls);
if (part.Type != null)
{
var typeName = part.Type;
query = query.Where(e => TypeNameMatches(e, typeName));
}
foreach (var pseudo in part.Pseudos)
{
if (!TryApplyPseudo(ref query, pseudo.Name, pseudo.Negate))
return false;
}
}
return true;
}
// Split a selector into simple parts, flagging each with whether it follows a '>' (child)
// combinator. Whitespace separates descendant parts; '>' is treated as its own token.
static List<(bool isChild, string part)> Tokenize(string selector)
{
var result = new List<(bool, string)>();
var raw = selector.Replace(">", " > ")
.Split((char[])null, StringSplitOptions.RemoveEmptyEntries);
bool nextIsChild = false;
foreach (var token in raw)
{
if (token == ">")
{
nextIsChild = true;
continue;
}
result.Add((nextIsChild, token));
nextIsChild = false;
}
return result;
}
class SelectorPart
{
public string Type;
public string Name;
public readonly List Classes = new List();
public readonly List<(bool Negate, string Name)> Pseudos = new List<(bool, string)>();
}
// Parse "Type#name.class1.class2" with optional ":pseudo" / ":not(pseudo)" suffixes.
static bool TryParsePart(string raw, out SelectorPart part)
{
part = new SelectorPart();
var simple = ExtractPseudos(raw, part.Pseudos);
if (simple == null)
return false;
int i = 0;
var sb = new StringBuilder();
// Leading run (before any '#' or '.') is the type token.
while (i < simple.Length && simple[i] != '#' && simple[i] != '.')
sb.Append(simple[i++]);
if (sb.Length > 0)
{
var type = sb.ToString();
if (type != "*")
part.Type = type;
}
while (i < simple.Length)
{
char kind = simple[i++];
sb.Clear();
while (i < simple.Length && simple[i] != '#' && simple[i] != '.')
sb.Append(simple[i++]);
var value = sb.ToString();
if (value.Length == 0)
return false; // dangling '#' or '.'
if (kind == '#')
{
if (part.Name != null)
return false; // more than one id
part.Name = value;
}
else
{
part.Classes.Add(value);
}
}
return true;
}
// Strip ":pseudo" and ":not(pseudo)" suffixes into 'pseudos', returning the simple-selector
// remainder, or null if a pseudo is malformed.
static string ExtractPseudos(string raw, List<(bool Negate, string Name)> pseudos)
{
int idx;
while ((idx = raw.IndexOf(':')) >= 0)
{
var head = raw.Substring(0, idx);
var rest = raw.Substring(idx + 1);
if (rest.StartsWith("not(", StringComparison.Ordinal))
{
int close = rest.IndexOf(')');
if (close < 0)
return null;
var inner = rest.Substring(4, close - 4).Trim().TrimStart(':');
if (inner.Length == 0)
return null;
pseudos.Add((true, inner.ToLowerInvariant()));
raw = head + rest.Substring(close + 1);
}
else
{
int next = rest.IndexOf(':');
var name = next < 0 ? rest : rest.Substring(0, next);
if (name.Length == 0)
return null;
pseudos.Add((false, name.ToLowerInvariant()));
raw = head + (next < 0 ? string.Empty : rest.Substring(next));
}
}
return raw;
}
// Apply one pseudo-state to the query, honoring negation. Returns false for an unknown state.
static bool TryApplyPseudo(ref UQueryBuilder query, string name, bool negate)
{
bool on = !negate;
switch (name)
{
case "checked": query = on ? query.Checked() : query.NotChecked(); return true;
case "hover": query = on ? query.Hovered() : query.NotHovered(); return true;
case "focus": query = on ? query.Focused() : query.NotFocused(); return true;
case "active": query = on ? query.Active() : query.NotActive(); return true;
case "enabled": query = on ? query.Enabled() : query.NotEnabled(); return true;
case "disabled": query = on ? query.NotEnabled() : query.Enabled(); return true;
default: return false;
}
}
// Match a type token against the element's runtime type name or any of its base type names,
// so "Button" matches a Button and "VisualElement" matches everything. Avoids reflecting the
// generic OfType over a runtime-resolved Type.
static bool TypeNameMatches(VisualElement element, string typeName)
{
for (var t = element.GetType(); t != null && t != typeof(object); t = t.BaseType)
{
if (string.Equals(t.Name, typeName, StringComparison.Ordinal))
return true;
}
return false;
}
static void DestroyObject(Object obj)
{
#if UNITY_EDITOR
if (!Application.isPlaying)
{
Object.DestroyImmediate(obj);
return;
}
#endif
Object.Destroy(obj);
}
}
///
/// Result of a visual-element capture command: the PNG payload (base64), its dimensions, the
/// panel/source it was rendered from, and the path it was written to. JSON property names mirror
/// the GameView capture command's result shape (PR #13) for easy convergence later.
///
[Serializable]
public class CaptureElementResponse
{
/// Whether the capture succeeded.
[JsonProperty("success")]
public bool Success { get; set; }
/// Human-readable message (the error text on failure).
[JsonProperty("message")]
public string Message { get; set; }
/// The selector used to locate the element.
[JsonProperty("selector")]
public string Selector { get; set; }
/// What was captured, e.g. "window:Inspector" or "panel:MainUI".
[JsonProperty("source")]
public string Source { get; set; }
/// Absolute filesystem path the PNG was written to.
[JsonProperty("path")]
public string Path { get; set; }
/// Captured width in pixels.
[JsonProperty("width")]
public int Width { get; set; }
/// Captured height in pixels.
[JsonProperty("height")]
public int Height { get; set; }
/// Image encoding; always "png".
[JsonProperty("encoding")]
public string Encoding { get; set; }
/// Base64-encoded PNG bytes.
[JsonProperty("base64")]
public string Base64 { get; set; }
/// Length of the raw PNG byte array.
[JsonProperty("bytes")]
public int Bytes { get; set; }
public static CaptureElementResponse Fail(string message) => new CaptureElementResponse
{
Success = false,
Message = message
};
}
}
#endif