#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