Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 113bc2bcfe487eb469aa654e6718f78b
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using UnityEditor;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// Groups all Undo operations registered during its lifetime into a single, collapsible Editor
|
||||
/// Undo step, so a multi-step agent action reverts as one. Register mutations inside the scope
|
||||
/// with Undo.RegisterCreatedObjectUndo / RegisterCompleteObjectUndo / etc.
|
||||
/// </para>
|
||||
///
|
||||
/// <code>
|
||||
/// using (new AuthoringUndoScope("Create Enemy"))
|
||||
/// {
|
||||
/// var go = new GameObject("Enemy");
|
||||
/// Undo.RegisterCreatedObjectUndo(go, "Create Enemy");
|
||||
/// // ... further registered mutations collapse into the same step
|
||||
/// }
|
||||
/// </code>
|
||||
///
|
||||
/// <para>
|
||||
/// NOTE: minimal seed for the shared safety policy (CAT-2509). AssetDatabase operations
|
||||
/// (folder/asset creation, import) are NOT part of Unity's Undo system, so this scope only
|
||||
/// affects scene/object mutations.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AuthoringUndoScope : IDisposable
|
||||
{
|
||||
private readonly int m_Group;
|
||||
private bool m_Canceled;
|
||||
|
||||
/// <summary>Start a new collapsible Undo group.</summary>
|
||||
/// <param name="name">Name shown for the collapsed Undo step.</param>
|
||||
public AuthoringUndoScope(string name)
|
||||
{
|
||||
Undo.IncrementCurrentGroup();
|
||||
m_Group = Undo.GetCurrentGroup();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
Undo.SetCurrentGroupName(name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Skip the collapse on dispose. Call after the scope's registered operations have been
|
||||
/// reverted wholesale (e.g. <c>Undo.RevertAllDownToGroup(group)</c> on a transactional batch
|
||||
/// abort): the group id no longer refers to this scope's operations on the Undo stack, and
|
||||
/// collapsing a discarded id could fold whatever now sits at that index into a stray group.
|
||||
/// </summary>
|
||||
public void Cancel() => m_Canceled = true;
|
||||
|
||||
/// <summary>Collapse all Undo operations registered during this scope into one step, unless <see cref="Cancel"/> was called.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (!m_Canceled)
|
||||
Undo.CollapseUndoOperations(m_Group);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6ef72626627670a4eadfccdab20cd9ba
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,256 @@
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves an agent-supplied <see cref="ObjectRef"/> handle to a live UnityEngine.Object, and
|
||||
/// describes any object back into a canonical <see cref="AuthoringResult"/> identity.
|
||||
/// Backed by GlobalObjectId, which addresses both assets (GUID + fileId) and scene objects.
|
||||
/// </summary>
|
||||
public static class ObjectResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Try to resolve a handle to a loaded object. Returns false with an <paramref name="error"/>
|
||||
/// when the handle is empty or does not resolve.
|
||||
/// </summary>
|
||||
/// <param name="handle">The agent-supplied object reference.</param>
|
||||
/// <param name="obj">The resolved object, or null on failure.</param>
|
||||
/// <param name="error">A human-readable reason on failure, or null on success.</param>
|
||||
/// <returns>True if the handle resolved.</returns>
|
||||
public static bool TryResolve(ObjectRef handle, out Object obj, out string error)
|
||||
{
|
||||
obj = null;
|
||||
error = null;
|
||||
|
||||
if (handle == null || handle.IsEmpty)
|
||||
{
|
||||
error = "Empty object reference.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. Canonical GlobalObjectId.
|
||||
if (!string.IsNullOrEmpty(handle.GlobalId))
|
||||
{
|
||||
if (!GlobalObjectId.TryParse(handle.GlobalId, out var gid))
|
||||
{
|
||||
error = $"Invalid globalId '{handle.GlobalId}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
obj = GlobalObjectId.GlobalObjectIdentifierToObjectSlow(gid);
|
||||
if (obj != null)
|
||||
return true;
|
||||
|
||||
error = $"globalId '{handle.GlobalId}' did not resolve to a loaded object.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Asset path. Explicit "Assets/"/"Packages/"-rooted paths load as-is; a bare relative
|
||||
// path (e.g. "Materials/Floor.mat") is normalized under the authoring root first. If it
|
||||
// does not resolve to an asset, fall back to a hierarchy lookup so dotted GameObject names
|
||||
// (e.g. "Cube.001") still resolve, and report every strategy that was tried.
|
||||
if (!string.IsNullOrEmpty(handle.Path))
|
||||
{
|
||||
var raw = handle.Path;
|
||||
var explicitlyRooted = IsExplicitlyRooted(raw);
|
||||
var assetPath = raw;
|
||||
if (!explicitlyRooted)
|
||||
{
|
||||
var resolved = ProjectPaths.Resolve(raw, out _);
|
||||
if (!string.IsNullOrEmpty(resolved))
|
||||
assetPath = resolved;
|
||||
}
|
||||
|
||||
obj = AssetDatabase.LoadMainAssetAtPath(assetPath);
|
||||
if (obj != null)
|
||||
return true;
|
||||
|
||||
if (explicitlyRooted)
|
||||
{
|
||||
error = $"No asset at path '{raw}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// A bare relative string may actually be a scene hierarchy path with a dotted name.
|
||||
var go = FindByHierarchyPath(raw);
|
||||
if (go != null)
|
||||
{
|
||||
obj = go;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = $"Could not resolve '{raw}': no asset at '{assetPath}', no GameObject at hierarchy path '{raw}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. GUID (+ optional fileId for sub-assets).
|
||||
if (!string.IsNullOrEmpty(handle.Guid))
|
||||
{
|
||||
var path = AssetDatabase.GUIDToAssetPath(handle.Guid);
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
error = $"Unknown GUID '{handle.Guid}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (handle.FileId.HasValue)
|
||||
{
|
||||
foreach (var candidate in AssetDatabase.LoadAllAssetsAtPath(path))
|
||||
{
|
||||
if (candidate != null &&
|
||||
AssetDatabase.TryGetGUIDAndLocalFileIdentifier(candidate, out _, out long localId) &&
|
||||
localId == handle.FileId.Value)
|
||||
{
|
||||
obj = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
error = $"No sub-asset with fileId {handle.FileId} in '{path}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
obj = AssetDatabase.LoadMainAssetAtPath(path);
|
||||
if (obj != null)
|
||||
return true;
|
||||
|
||||
error = $"Could not load asset '{path}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// 4. Instance id (loaded/scene object).
|
||||
if (handle.InstanceId.HasValue)
|
||||
{
|
||||
obj = PipelineUtils.IdToObject(handle.InstanceId.Value);
|
||||
if (obj != null)
|
||||
return true;
|
||||
|
||||
error = $"No loaded object with instanceId {handle.InstanceId}.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. Scene hierarchy path.
|
||||
if (!string.IsNullOrEmpty(handle.HierarchyPath))
|
||||
{
|
||||
var go = FindByHierarchyPath(handle.HierarchyPath);
|
||||
if (go != null)
|
||||
{
|
||||
obj = go;
|
||||
return true;
|
||||
}
|
||||
|
||||
error = $"No GameObject at hierarchy path '{handle.HierarchyPath}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
error = "Empty object reference.";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Produce the canonical identity for an object (assets get path/guid/fileId; loaded objects
|
||||
/// get instanceId/hierarchyPath). Returns null for a null object.
|
||||
/// </summary>
|
||||
/// <param name="obj">The object to describe.</param>
|
||||
/// <returns>The object's canonical identity, or null if <paramref name="obj"/> is null.</returns>
|
||||
public static AuthoringResult Describe(Object obj)
|
||||
{
|
||||
if (obj == null)
|
||||
return null;
|
||||
|
||||
var result = new AuthoringResult { Type = obj.GetType().Name };
|
||||
|
||||
var assetPath = AssetDatabase.GetAssetPath(obj);
|
||||
if (!string.IsNullOrEmpty(assetPath))
|
||||
{
|
||||
result.AssetPath = assetPath;
|
||||
if (AssetDatabase.TryGetGUIDAndLocalFileIdentifier(obj, out var guid, out long localId))
|
||||
{
|
||||
result.Guid = guid;
|
||||
result.FileId = localId;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
result.InstanceId = PipelineUtils.GetObjectId(obj);
|
||||
var go = obj as GameObject ?? (obj as Component)?.gameObject;
|
||||
if (go != null)
|
||||
result.HierarchyPath = GetHierarchyPath(go);
|
||||
}
|
||||
|
||||
// Not every object has a global id (e.g. transient objects); ignore failures.
|
||||
try
|
||||
{
|
||||
result.GlobalId = GlobalObjectId.GetGlobalObjectIdSlow(obj).ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// leave GlobalId null
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>True when a path is explicitly rooted at "Assets/" or "Packages/" (or is exactly one of those).</summary>
|
||||
private static bool IsExplicitlyRooted(string path) =>
|
||||
path == "Assets" || path == "Packages"
|
||||
|| path.StartsWith("Assets/", System.StringComparison.Ordinal)
|
||||
|| path.StartsWith("Packages/", System.StringComparison.Ordinal);
|
||||
|
||||
private static GameObject FindByHierarchyPath(string path)
|
||||
{
|
||||
var parts = path.Trim('/').Split('/');
|
||||
if (parts.Length == 0 || string.IsNullOrEmpty(parts[0]))
|
||||
return null;
|
||||
|
||||
for (int s = 0; s < SceneManager.sceneCount; s++)
|
||||
{
|
||||
var scene = SceneManager.GetSceneAt(s);
|
||||
if (!scene.isLoaded)
|
||||
continue;
|
||||
|
||||
foreach (var root in scene.GetRootGameObjects())
|
||||
{
|
||||
if (root.name != parts[0])
|
||||
continue;
|
||||
|
||||
var current = root.transform;
|
||||
var matched = true;
|
||||
for (int i = 1; i < parts.Length; i++)
|
||||
{
|
||||
var child = current.Find(parts[i]);
|
||||
if (child == null)
|
||||
{
|
||||
matched = false;
|
||||
break;
|
||||
}
|
||||
|
||||
current = child;
|
||||
}
|
||||
|
||||
if (matched)
|
||||
return current.gameObject;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string GetHierarchyPath(GameObject go)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(go.name);
|
||||
var parent = go.transform.parent;
|
||||
while (parent != null)
|
||||
{
|
||||
sb.Insert(0, parent.name + "/");
|
||||
parent = parent.parent;
|
||||
}
|
||||
|
||||
return "/" + sb;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 26becd8c305c973488fa15b9ea058a00
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,148 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Project-path handling for authoring commands.
|
||||
///
|
||||
/// - Paths resolve relative to the configurable <see cref="AuthoringRoot"/> (default "Assets"),
|
||||
/// so callers can pass bare paths ("Gameplay/Enemies") without repeating the "Assets/" prefix.
|
||||
/// - Explicit "Assets/..." (or "Packages/...") paths are treated as project-relative as-is.
|
||||
/// - Every resolved path is confined to <see cref="AuthoringRoot"/> (the sandbox). With the
|
||||
/// default root ("Assets") that means full Assets access; set a sub-folder to confine an agent.
|
||||
/// - "../" traversal and writes outside the project root are always rejected.
|
||||
///
|
||||
/// NOTE: minimal seed for the shared safety policy (CAT-2509).
|
||||
/// </summary>
|
||||
public static class ProjectPaths
|
||||
{
|
||||
private const string DefaultRoot = "Assets";
|
||||
|
||||
// Per-project EditorPrefs key so the root doesn't leak across projects on the same machine.
|
||||
private static string PrefKey => $"Unity.Pipeline.AuthoringRoot:{Application.dataPath}";
|
||||
|
||||
/// <summary>Absolute path to the project root (the folder that contains Assets/).</summary>
|
||||
public static string ProjectRoot => Path.GetDirectoryName(Application.dataPath);
|
||||
|
||||
/// <summary>
|
||||
/// Base folder (project-relative, under Assets/) that bare paths resolve against and that all
|
||||
/// authoring writes are confined to. Defaults to "Assets". Persisted per project via EditorPrefs.
|
||||
/// Setting an invalid value (outside Assets/ or containing "..") throws.
|
||||
/// </summary>
|
||||
public static string AuthoringRoot
|
||||
{
|
||||
get
|
||||
{
|
||||
var root = EditorPrefs.GetString(PrefKey, DefaultRoot);
|
||||
return string.IsNullOrEmpty(root) ? DefaultRoot : root;
|
||||
}
|
||||
set
|
||||
{
|
||||
var normalized = NormalizeRoot(value, out var error);
|
||||
if (normalized == null)
|
||||
throw new ArgumentException(error);
|
||||
EditorPrefs.SetString(PrefKey, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reset the authoring root to the default ("Assets").</summary>
|
||||
public static void ResetAuthoringRoot() => EditorPrefs.DeleteKey(PrefKey);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve an agent-supplied path to a normalized, project-relative path confined to
|
||||
/// <see cref="AuthoringRoot"/>. Bare paths are taken relative to the root; explicit
|
||||
/// "Assets/..." paths and absolute paths under the project are honored. Returns null with an
|
||||
/// <paramref name="error"/> when the path escapes the root or the project.
|
||||
/// </summary>
|
||||
/// <param name="path">The agent-supplied path to resolve.</param>
|
||||
/// <param name="error">A human-readable reason on failure, or null on success.</param>
|
||||
/// <returns>The normalized, project-relative path, or null on failure.</returns>
|
||||
public static string Resolve(string path, out string error)
|
||||
{
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
error = "Path is required.";
|
||||
return null;
|
||||
}
|
||||
|
||||
var root = AuthoringRoot;
|
||||
var normalized = path.Replace('\\', '/').Trim();
|
||||
|
||||
// Absolute path: must live under the project root; convert to project-relative.
|
||||
if (Path.IsPathRooted(normalized))
|
||||
{
|
||||
var full = Path.GetFullPath(normalized).Replace('\\', '/');
|
||||
var projectRoot = ProjectRoot.Replace('\\', '/').TrimEnd('/');
|
||||
if (!full.Equals(projectRoot, StringComparison.OrdinalIgnoreCase) &&
|
||||
!full.StartsWith(projectRoot + "/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
error = $"Path '{path}' is outside the project root '{projectRoot}'.";
|
||||
return null;
|
||||
}
|
||||
|
||||
normalized = full.Length > projectRoot.Length ? full.Substring(projectRoot.Length + 1) : string.Empty;
|
||||
}
|
||||
|
||||
normalized = normalized.TrimStart('/');
|
||||
|
||||
if (normalized.Split('/').Contains(".."))
|
||||
{
|
||||
error = $"Path '{path}' must not contain '..'.";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Explicit project-relative paths ("Assets/..." / "Packages/...") are used as-is; anything
|
||||
// else is a bare path taken relative to the authoring root.
|
||||
var isProjectRelative =
|
||||
normalized == "Assets" || normalized.StartsWith("Assets/", StringComparison.Ordinal) ||
|
||||
normalized == "Packages" || normalized.StartsWith("Packages/", StringComparison.Ordinal);
|
||||
|
||||
var resolved = (isProjectRelative ? normalized : CombineUnder(root, normalized)).TrimEnd('/');
|
||||
|
||||
// Confine to the authoring root (default "Assets" => full Assets access).
|
||||
if (!(resolved == root || resolved.StartsWith(root + "/", StringComparison.Ordinal)))
|
||||
{
|
||||
error = $"Path '{path}' resolves to '{resolved}', which is outside the authoring root '{root}'.";
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static string CombineUnder(string root, string relative)
|
||||
{
|
||||
relative = relative.TrimStart('/');
|
||||
return string.IsNullOrEmpty(relative) ? root : $"{root}/{relative}";
|
||||
}
|
||||
|
||||
private static string NormalizeRoot(string value, out string error)
|
||||
{
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
error = "Authoring root is required.";
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = value.Replace('\\', '/').Trim().TrimStart('/').TrimEnd('/');
|
||||
if (normalized.Split('/').Contains(".."))
|
||||
{
|
||||
error = $"Authoring root '{value}' must not contain '..'.";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!(normalized == "Assets" || normalized.StartsWith("Assets/", StringComparison.Ordinal)))
|
||||
{
|
||||
error = $"Authoring root '{value}' must be under the project's Assets/ folder.";
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 462c39b6cec1060408ca75a32bf31d89
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a4d02bf29da41d499ea151c638ee920
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+355
@@ -0,0 +1,355 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
using UnityEditor.Build.Reporting;
|
||||
using UnityEditor.Compilation;
|
||||
using UnityEditor.UnityLinker;
|
||||
using UnityEngine;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
using IlInterpreter.Interpreter;
|
||||
using Assembly = System.Reflection.Assembly;
|
||||
|
||||
namespace Unity.Pipeline.Editor.BuildProcessors
|
||||
{
|
||||
/// <summary>
|
||||
/// Contributes an auto-generated <c>link.xml</c> that preserves the host-binding surface the
|
||||
/// interpreter exposes to reloaded / eval'd code, so IL2CPP managed stripping cannot remove
|
||||
/// members the built game never referenced (symptom: "Host method '…' is not registered in the
|
||||
/// binding", on device only).
|
||||
///
|
||||
/// Unity's own generated <c>link.xml</c> can't cover this: it preserves what the build *references*,
|
||||
/// and the whole point of code reload is to call members the build never referenced. So preserve
|
||||
/// (a) every type in <see cref="HostBinding.RegisteredTypes"/> with <c>preserve="all"</c>, and
|
||||
/// (b) — because demand-time auto-bind (<see cref="HostBinding.AutoBindResolver"/>) lets reloaded
|
||||
/// code reach ANY user or UnityEngine type — the build's user assemblies and UnityEngine modules
|
||||
/// wholesale.
|
||||
///
|
||||
/// Development builds only: the interpreter reload/eval sinks compile out of release players.
|
||||
/// Also preserves the interpreter engine itself (IlInterpreter + its reflection/metadata
|
||||
/// dependencies) — kept here, rather than in a static <c>Runtime/link.xml</c>, precisely so
|
||||
/// release players don't pay the size cost for machinery they compile out.
|
||||
/// </summary>
|
||||
class CodeReloadLinkXmlGenerator : IUnityLinkerProcessor
|
||||
{
|
||||
public int callbackOrder => 0;
|
||||
|
||||
public string GenerateAdditionalLinkXmlFile(BuildReport report, UnityLinkerBuildPipelineData data)
|
||||
{
|
||||
// Only development builds carry the interpreter; release builds need no extra preservation.
|
||||
if (report == null || !report.summary.options.HasFlag(BuildOptions.Development))
|
||||
return string.Empty;
|
||||
|
||||
// The managed assemblies staged for this player build — the universe for the wholesale
|
||||
// preservation pass. See CollectPlayerAssemblyNames: it unions the linker input directory
|
||||
// with the editor's own player-assembly view, because on some build backends the input
|
||||
// directory is empty at IUnityLinkerProcessor time and relying on it alone silently
|
||||
// collapsed preservation to the explicit per-type surface — stripping everything
|
||||
// demand-time auto-bind reaches (e.g. a live-added `new Slider()`, whose UITK ctors the
|
||||
// built game never referenced) on device only.
|
||||
// inputDirectory is obsolete on the incremental build pipeline (it comes back empty there),
|
||||
// which is exactly the gap the union above closes; on the classic pipeline it is still the
|
||||
// exact staged set, so keep reading it.
|
||||
#pragma warning disable CS0618
|
||||
var playerAssemblies = CollectPlayerAssemblyNames(data.inputDirectory);
|
||||
#pragma warning restore CS0618
|
||||
|
||||
var path = Path.GetFullPath(FileUtil.GetUniqueTempPathInProject() + "-hostbindings.link.xml");
|
||||
File.WriteAllText(path, GenerateLinkXml(playerAssemblies, out var stats));
|
||||
// The stats line is the diagnostic: if a game assembly with [CodeReload] types is missing
|
||||
// from the reloadable-target list (e.g. its name matches the engine-assembly skip
|
||||
// prefixes), its private state can be stripped and binding fails on device only.
|
||||
Debug.Log($"Pipeline: generated code-reload host-binding link.xml at {path} — {stats}");
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The simple names of the managed assemblies that end up in this player build — the universe
|
||||
/// the wholesale-preservation pass draws from. Unions two sources so a gap in either can't
|
||||
/// silently collapse preservation:
|
||||
/// <list type="bullet">
|
||||
/// <item>the linker input directory (the exact staged set, incl. test assemblies for a test
|
||||
/// player) — recursively, since some backends nest the managed DLLs in a subfolder;</item>
|
||||
/// <item>the editor's own view — <see cref="CompilationPipeline"/> player assemblies (user
|
||||
/// code) plus every loaded <c>UnityEngine*</c> module (the precompiled engine modules
|
||||
/// demand-time auto-bind can construct from) — which stays populated even when the input
|
||||
/// directory is empty at <see cref="IUnityLinkerProcessor"/> time.</item>
|
||||
/// </list>
|
||||
/// A union only adds; it never drops an assembly the input scan found, so a test player keeps
|
||||
/// its test assemblies. Returns null only if every source came up empty (preserve per-type only).
|
||||
/// </summary>
|
||||
internal static string[] CollectPlayerAssemblyNames(string inputDirectory)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(inputDirectory) && Directory.Exists(inputDirectory))
|
||||
foreach (var dll in Directory.GetFiles(inputDirectory, "*.dll", SearchOption.AllDirectories))
|
||||
names.Add(Path.GetFileNameWithoutExtension(dll));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"Pipeline: could not enumerate linker input assemblies " +
|
||||
$"('{inputDirectory}'): {ex.Message} — falling back to the editor's " +
|
||||
"player-assembly view for wholesale preservation.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var a in CompilationPipeline.GetAssemblies(AssembliesType.Player))
|
||||
if (!string.IsNullOrEmpty(a.name))
|
||||
names.Add(a.name);
|
||||
}
|
||||
catch { /* editor API unavailable in this context — the UnityEngine sweep still runs */ }
|
||||
|
||||
foreach (var a in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
var n = a.GetName().Name;
|
||||
if (!string.IsNullOrEmpty(n) && n.StartsWith("UnityEngine", StringComparison.Ordinal))
|
||||
names.Add(n);
|
||||
}
|
||||
|
||||
return names.Count > 0 ? new List<string>(names).ToArray() : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the interpreter's default host binding and emit a <c>link.xml</c> preserving every
|
||||
/// registered type, grouped by owning assembly. Pure and deterministic — directly unit-testable.
|
||||
/// Derives from <see cref="IlInterpreterHostBindings.CreateStandard"/> — the builder the code-reload
|
||||
/// executor runs — so the preservation set cannot drift from the runtime surface. The per-reload
|
||||
/// <c>AllowType(targetType)</c> overlay is excluded: the target is the user's own component,
|
||||
/// which the built player references and IL2CPP won't strip.
|
||||
/// </summary>
|
||||
public static string GenerateLinkXml() => GenerateLinkXml(null, out _);
|
||||
|
||||
public static string GenerateLinkXml(out string stats) => GenerateLinkXml(null, out stats);
|
||||
|
||||
/// <summary><paramref name="stats"/>: one-line summary of what was preserved, logged at build
|
||||
/// time so a missing game assembly is visible in the build log.
|
||||
/// <paramref name="playerAssemblyNames"/>: simple names of the managed assemblies staged for
|
||||
/// the player build; when provided, user assemblies and UnityEngine modules from that set are
|
||||
/// preserved wholesale (including engine-named assemblies carrying [CodeReload] members — the
|
||||
/// attribute marks them as user code), and reload-target entries are limited to assemblies in
|
||||
/// the set (editor-only and test assemblies drop out of game builds); when null, only per-type
|
||||
/// preservation is emitted, unfiltered.</summary>
|
||||
public static string GenerateLinkXml(IEnumerable<string> playerAssemblyNames, out string stats)
|
||||
{
|
||||
var binding = IlInterpreterHostBindings.CreateStandard();
|
||||
|
||||
// SortedSet/SortedDictionary give a stable, diffable ordering and dedupe (closed generics
|
||||
// collapsing onto their definition can produce repeats).
|
||||
var byAssembly = new SortedDictionary<string, SortedSet<string>>(StringComparer.Ordinal);
|
||||
foreach (var t in binding.RegisteredTypes)
|
||||
{
|
||||
// Preserve the open definition for constructed generics (a closed-generic fullname is
|
||||
// assembly-qualified and won't match in link.xml).
|
||||
var def = t.IsGenericType && !t.IsGenericTypeDefinition ? t.GetGenericTypeDefinition() : t;
|
||||
|
||||
var asm = def.Assembly.GetName().Name;
|
||||
var full = def.FullName;
|
||||
if (string.IsNullOrEmpty(asm) || string.IsNullOrEmpty(full))
|
||||
continue;
|
||||
|
||||
full = full.Replace('+', '/'); // link.xml uses '/' as the nested-type separator
|
||||
|
||||
if (!byAssembly.TryGetValue(asm, out var types))
|
||||
byAssembly[asm] = types = new SortedSet<string>(StringComparer.Ordinal);
|
||||
types.Add(full);
|
||||
}
|
||||
|
||||
// Also preserve the reloadable target components. The target type IS referenced by the
|
||||
// built player, but a PRIVATE field only the interpreter touches can still be stripped,
|
||||
// breaking the reflection walk; preserve="all" keeps every member so
|
||||
// AllowNonPublicInstanceMembers(targetType) can resolve private state on device.
|
||||
int hostTypeCount = 0;
|
||||
foreach (var kv in byAssembly)
|
||||
hostTypeCount += kv.Value.Count;
|
||||
|
||||
// The AppDomain scan below also sees editor-only and test assemblies that are not part
|
||||
// of the player build; when the staged set is known, reload targets outside it are
|
||||
// dropped as noise. A test-player build stages its test assemblies, so fixtures keep
|
||||
// their entries exactly where they matter.
|
||||
HashSet<string> staged = null;
|
||||
if (playerAssemblyNames != null)
|
||||
{
|
||||
staged = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var name in playerAssemblyNames)
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
staged.Add(name);
|
||||
}
|
||||
|
||||
int targetTypeCount = 0;
|
||||
var targetAssemblies = new SortedSet<string>(StringComparer.Ordinal);
|
||||
var engineNamedTargetAssemblies = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var t in CollectReloadableTargetTypes())
|
||||
{
|
||||
var asm = t.Assembly.GetName().Name;
|
||||
var full = t.FullName;
|
||||
if (string.IsNullOrEmpty(asm) || string.IsNullOrEmpty(full))
|
||||
continue;
|
||||
if (staged != null && !staged.Contains(asm))
|
||||
continue;
|
||||
full = full.Replace('+', '/');
|
||||
if (!byAssembly.TryGetValue(asm, out var types))
|
||||
byAssembly[asm] = types = new SortedSet<string>(StringComparer.Ordinal);
|
||||
types.Add(full);
|
||||
targetTypeCount++;
|
||||
targetAssemblies.Add(asm);
|
||||
if (IsNonUserAssembly(asm))
|
||||
engineNamedTargetAssemblies.Add(asm);
|
||||
}
|
||||
|
||||
// Dev-build widening: demand-time auto-bind (HostBinding.AutoBindResolver) lets a reloaded
|
||||
// or eval'd body call into ANY user or UnityEngine type, so RegisteredTypes is not the full
|
||||
// callable set — preserve the build's user assemblies and UnityEngine modules wholesale.
|
||||
// Unity.* packages (Burst, Collections, …) stay per-type: AOT-heavy and rarely touched from
|
||||
// reload bodies. (The project-extension seam that could promote a specific type was
|
||||
// deferred to a future release, so on-device reload bodies can only reach package types
|
||||
// the stripper kept.)
|
||||
var wholesale = new SortedSet<string>(StringComparer.Ordinal);
|
||||
// The interpreter engine itself: it parses pushed override IL via
|
||||
// System.Reflection.Metadata and resolves host members by name, so those paths look
|
||||
// unused to the stripper. Emitted here (dev builds only) rather than in a static
|
||||
// Runtime/link.xml, which would bloat release players that compile the interpreter out.
|
||||
foreach (var name in InterpreterEngineAssemblies)
|
||||
wholesale.Add(name);
|
||||
if (staged != null)
|
||||
{
|
||||
foreach (var name in staged)
|
||||
{
|
||||
if (name.StartsWith("UnityEngine", StringComparison.Ordinal)) wholesale.Add(name);
|
||||
else if (!IsNonUserAssembly(name)) wholesale.Add(name);
|
||||
}
|
||||
|
||||
// A staged, engine-named assembly carrying [CodeReload] members has identified itself
|
||||
// as user code participating in code reload — a package-like name (e.g. "Unity.MyGame")
|
||||
// must not demote it. Preserving only its [CodeReload] types would leave the rest of
|
||||
// it strippable (demand-time auto-bind would hit stripped members on device only),
|
||||
// so promote it into the wholesale set like any other user assembly.
|
||||
foreach (var asm in engineNamedTargetAssemblies)
|
||||
wholesale.Add(asm);
|
||||
}
|
||||
|
||||
stats = $"{hostTypeCount} host-binding type(s) and {targetTypeCount} reloadable target type(s) " +
|
||||
$"across {byAssembly.Count} assembly(ies); target assemblies: " +
|
||||
(targetAssemblies.Count > 0 ? string.Join(", ", targetAssemblies) : "none") +
|
||||
$"; {wholesale.Count} assembly(ies) preserved wholesale for demand-time auto-bind";
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<linker>");
|
||||
sb.AppendLine(" <!--");
|
||||
sb.AppendLine(" AUTO-GENERATED by CodeReloadLinkXmlGenerator (development builds only). Do not edit.");
|
||||
sb.AppendLine(" Preserves the IlInterpreter interpreter engine and its host-binding surface");
|
||||
sb.AppendLine(" (HostBinding.RegisteredTypes), plus — wholesale — the build's user assemblies and");
|
||||
sb.AppendLine(" UnityEngine modules, so reloaded/eval'd code can call members the built game");
|
||||
sb.AppendLine(" never referenced (including types the interpreter binds on demand), which IL2CPP");
|
||||
sb.AppendLine(" managed stripping would otherwise remove.");
|
||||
sb.AppendLine(" -->");
|
||||
// Wholesale assemblies first (their per-type entries are subsumed), then per-type blocks.
|
||||
foreach (var name in wholesale)
|
||||
sb.AppendLine($" <assembly fullname=\"{name}\" preserve=\"all\"/>");
|
||||
foreach (var kv in byAssembly)
|
||||
{
|
||||
if (wholesale.Contains(kv.Key)) continue;
|
||||
sb.AppendLine($" <assembly fullname=\"{kv.Key}\">");
|
||||
foreach (var type in kv.Value)
|
||||
sb.AppendLine($" <type fullname=\"{type}\" preserve=\"all\"/>");
|
||||
sb.AppendLine(" </assembly>");
|
||||
}
|
||||
sb.AppendLine("</linker>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// The IlInterpreter engine and its reflection/metadata dependencies — always preserved
|
||||
// wholesale so pushed code reload keeps working on device.
|
||||
static readonly string[] InterpreterEngineAssemblies =
|
||||
{
|
||||
"Unity.Pipeline.IlInterpreter",
|
||||
"UnityPipeline.System.Reflection.Metadata",
|
||||
"UnityPipeline.System.Collections.Immutable",
|
||||
};
|
||||
|
||||
// Engine/BCL/tooling assembly names — NOT user code. Decides which staged assemblies are
|
||||
// preserved wholesale, and which reload-target assemblies get the engine-named warning.
|
||||
// Matching is deliberately precise (exact "Unity"/"System" or a dotted prefix, never the
|
||||
// bare word) so a game assembly that merely starts with the word — "UnityChanDemo",
|
||||
// "SystemsRuntime" — is still preserved wholesale.
|
||||
static bool IsNonUserAssembly(string name) =>
|
||||
name == "Unity" ||
|
||||
name.StartsWith("Unity.", StringComparison.Ordinal) ||
|
||||
name.StartsWith("UnityEngine", StringComparison.Ordinal) ||
|
||||
name.StartsWith("UnityEditor", StringComparison.Ordinal) ||
|
||||
name == "System" ||
|
||||
name.StartsWith("System.", StringComparison.Ordinal) ||
|
||||
name.StartsWith("Microsoft.", StringComparison.Ordinal) ||
|
||||
name.StartsWith("Mono.", StringComparison.Ordinal) ||
|
||||
name == "mscorlib" ||
|
||||
name == "netstandard" ||
|
||||
name.StartsWith("nunit.", StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// User-code types that carry a class-level <c>[CodeReload]</c> or any <c>[CodeReload]</c>
|
||||
/// method — i.e. every type whose members can be reloaded through
|
||||
/// the interpreter and therefore needs its (possibly private) fields kept under stripping.
|
||||
/// The attributes live in <c>Unity.Pipeline</c>, so only assemblies referencing it can carry
|
||||
/// them — a cheap, metadata-only check that gates the expensive type scan. Assembly *names*
|
||||
/// play no part here, so game assemblies named like packages ("Unity.MyGame") are covered.
|
||||
/// </summary>
|
||||
static IEnumerable<Type> CollectReloadableTargetTypes()
|
||||
{
|
||||
var results = new List<Type>();
|
||||
foreach (var asm in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
if (!ReferencesPipelineRuntime(asm))
|
||||
continue;
|
||||
|
||||
Type[] types;
|
||||
try { types = asm.GetTypes(); }
|
||||
catch (ReflectionTypeLoadException ex) { types = ex.Types; }
|
||||
catch { continue; }
|
||||
|
||||
foreach (var t in types)
|
||||
{
|
||||
if (t == null) continue;
|
||||
try
|
||||
{
|
||||
if (t.IsDefined(typeof(CodeReloadAttribute), inherit: false) || HasReloadableMethod(t))
|
||||
results.Add(t);
|
||||
}
|
||||
catch { /* type that can't be inspected — skip */ }
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
static bool ReferencesPipelineRuntime(Assembly asm)
|
||||
{
|
||||
var pipeline = typeof(CodeReloadAttribute).Assembly.GetName().Name;
|
||||
try
|
||||
{
|
||||
foreach (var reference in asm.GetReferencedAssemblies())
|
||||
if (string.Equals(reference.Name, pipeline, StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
catch { /* dynamic/unloadable assembly — treat as not referencing */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool HasReloadableMethod(Type t)
|
||||
{
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic
|
||||
| BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly;
|
||||
foreach (var m in t.GetMethods(flags))
|
||||
{
|
||||
if (m.IsDefined(typeof(CodeReloadAttribute), inherit: false))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b802ce727e209d74bbbda4467462840a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Unity.Pipeline.Config;
|
||||
using UnityEngine;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
using UnityEditor.Build.Reporting;
|
||||
|
||||
namespace Unity.Pipeline.Editor.BuildProcessors
|
||||
{
|
||||
/// <summary>
|
||||
/// Build processor for runtime Pipeline support. Three responsibilities:
|
||||
/// - Validates the RuntimePipelineConfig settings (JSON-authored, read via
|
||||
/// RuntimePipelineConfig.Load()) before allowing builds with runtime Pipeline enabled.
|
||||
/// - Bakes the current settings into a transient RuntimePipelineConfig Resources asset so a
|
||||
/// Player build can find them (the authored copy stays in ProjectSettings/, never here).
|
||||
/// - Bakes the project's code reload scope (Assets + loaded package locations) into a
|
||||
/// generated RuntimePipelineBuildInfo asset. A running Player cannot resolve the project
|
||||
/// layout, so the absolute roots it is allowed to code reload from must be captured at build
|
||||
/// time.
|
||||
/// Both generated assets are purged at the very start of every build (before either is
|
||||
/// possibly rewritten) and deleted again after a successful build, so machine-specific/
|
||||
/// duplicated data never lingers in the project — including a stale, possibly-enabled asset
|
||||
/// left behind by a build that was interrupted before OnPostprocessBuild could run (Unity
|
||||
/// never invokes it for a failed or cancelled build).
|
||||
/// </summary>
|
||||
#if UNITY_6000_3_OR_NEWER
|
||||
class PipelineRuntimeBuildProcessor : IPreprocessBuildWithContext, IPostprocessBuildWithReport
|
||||
#else
|
||||
class PipelineRuntimeBuildProcessor : IPreprocessBuildWithReport, IPostprocessBuildWithReport
|
||||
#endif
|
||||
{
|
||||
/// <summary>Callback ordering relative to other build processors (0 = default).</summary>
|
||||
public int callbackOrder => 0;
|
||||
|
||||
private const string ConfigAssetPath = "Assets/Settings/Pipeline/Resources/RuntimePipelineConfig.asset";
|
||||
private const string BuildInfoAssetPath = "Assets/Settings/Pipeline/Resources/RuntimePipelineBuildInfo.asset";
|
||||
|
||||
// Opt-in symbol that puts the Pipeline into a build that is not a Development Build. Kept in
|
||||
// step with the defineConstraints on Unity.Pipeline / Unity.Pipeline.IlInterpreter and on the
|
||||
// bundled DLLs under Runtime/Plugins/CodeAnalysis: if this file and those disagree, a player
|
||||
// gets settings assets no code can read, or Pipeline code with no settings to start from.
|
||||
private const string EnableSymbol = "ENABLE_RUNTIME_PIPELINE";
|
||||
|
||||
/// <summary>Verify bundled DLL integrity and validate runtime pipeline configuration before a build.</summary>
|
||||
#if UNITY_6000_3_OR_NEWER
|
||||
/// <param name="ctx">The build callback context.</param>
|
||||
public void OnPreprocessBuild(BuildCallbackContext ctx)
|
||||
#else
|
||||
/// <param name="report">The build report.</param>
|
||||
public void OnPreprocessBuild(BuildReport report)
|
||||
#endif
|
||||
{
|
||||
// Purge any leftovers from a previous, interrupted build before anything else in this
|
||||
// method — every exit path below (no config, disabled, not a development build,
|
||||
// validation failure) must start from a clean slate. OnPostprocessBuild is not invoked
|
||||
// for a failed/cancelled build, so it can never be relied on for this;
|
||||
// this purge is the only mechanism that actually guarantees it. Without it, a stale
|
||||
// *enabled* asset left by an earlier interrupted build would still get packaged into a
|
||||
// build that is supposed to have Pipeline disabled.
|
||||
DeleteGeneratedAssetsIfPresent();
|
||||
|
||||
// Integrity gate: fail the build if a bundled Roslyn DLL was swapped or modified.
|
||||
VerifyBundledChecksums();
|
||||
|
||||
var config = RuntimePipelineConfig.Load();
|
||||
if (config == null)
|
||||
{
|
||||
Debug.LogWarning("Pipeline: No RuntimePipelineConfig asset found (Project Settings > Pipeline > Runtime). Pipeline will be disabled in Player builds.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!config.enableInBuilds)
|
||||
{
|
||||
Debug.LogWarning("Pipeline: RuntimePipelineConfig found, but enableInBuilds = false. Pipeline will be disabled in Player builds.");
|
||||
return;
|
||||
}
|
||||
|
||||
// A build that will not contain the Pipeline assemblies cannot read the generated
|
||||
// Resources assets, so baking them would ship dead, machine-specific data.
|
||||
if (!PipelineIsInBuild())
|
||||
{
|
||||
Debug.Log("Pipeline: enableInBuilds is on, but this build is neither a Development " +
|
||||
$"Build nor defines {EnableSymbol}, so the Pipeline assemblies and settings are " +
|
||||
"excluded from it.");
|
||||
return;
|
||||
}
|
||||
|
||||
var validationResult = config.Validate();
|
||||
if (!validationResult.IsValid)
|
||||
{
|
||||
throw new BuildFailedException($"Pipeline: Runtime configuration validation failed: {validationResult.Message}");
|
||||
}
|
||||
|
||||
if (validationResult.Level == "warning")
|
||||
{
|
||||
Debug.LogWarning($"Pipeline: Runtime configuration warning: {validationResult.Message}");
|
||||
}
|
||||
|
||||
// Push signing: ensure the project key exists and bake its public half into the
|
||||
// build info, so the player rejects any code-reload push not signed by the matching
|
||||
// private key. Log the fingerprint so a later key mismatch is diagnosable from the
|
||||
// build log alone.
|
||||
var privateKey = PushSigningKey.LoadOrCreate(PushSigningKey.DefaultDirectory, out var keyCreated);
|
||||
var pushPublicKey = Unity.Pipeline.CodeReload.PushEnvelope.DerivePublicKey(privateKey);
|
||||
Debug.Log($"Pipeline: push-signing key {(keyCreated ? "GENERATED" : "loaded")} " +
|
||||
$"(fingerprint {Unity.Pipeline.CodeReload.PushEnvelope.Fingerprint(pushPublicKey)}, " +
|
||||
$"Library/Pipeline/{PushSigningKey.KeyFileName}). This build only accepts pushes signed with it.");
|
||||
|
||||
WriteConfigAsset(config);
|
||||
WriteBuildInfoAsset(pushPublicKey);
|
||||
|
||||
Debug.Log("Pipeline: Runtime server ENABLED in build.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
UnityEngine.Object.DestroyImmediate(config);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this build will contain the Pipeline runtime assemblies, i.e. whether it satisfies
|
||||
/// their <c>UNITY_EDITOR || DEVELOPMENT_BUILD || ENABLE_RUNTIME_PIPELINE</c> define
|
||||
/// constraint. A scripted build (the <c>build</c> command, which calls BuildPlayer with
|
||||
/// <see cref="BuildOptions.Development"/>) is a development build even when the Build
|
||||
/// Settings toggle is off, so the command's active options count too.
|
||||
/// </summary>
|
||||
private static bool PipelineIsInBuild()
|
||||
{
|
||||
if (EditorUserBuildSettings.development)
|
||||
return true;
|
||||
|
||||
if (Commands.Build.BuildCommand.ActiveScriptedBuildOptions is { } scriptedOptions
|
||||
&& (scriptedOptions & BuildOptions.Development) != 0)
|
||||
return true;
|
||||
|
||||
var group = BuildPipeline.GetBuildTargetGroup(EditorUserBuildSettings.activeBuildTarget);
|
||||
var defines = PlayerSettings.GetScriptingDefineSymbols(NamedBuildTarget.FromBuildTargetGroup(group));
|
||||
return defines != null && defines.Split(';').Any(d => d.Trim() == EnableSymbol);
|
||||
}
|
||||
|
||||
public void OnPostprocessBuild(BuildReport report)
|
||||
{
|
||||
DeleteGeneratedAssetsIfPresent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Absolute roots considered in-scope for runtime code reload: the project's Assets folder
|
||||
/// plus the resolved location of every package loaded into the project (a local package may
|
||||
/// live anywhere on disk, not only under Packages).
|
||||
/// </summary>
|
||||
/// <returns>Absolute paths of the project's Assets folder and every loaded package.</returns>
|
||||
public static List<string> CollectProjectRoots()
|
||||
{
|
||||
var roots = new List<string> { Path.GetFullPath(Application.dataPath) };
|
||||
|
||||
foreach (var package in UnityEditor.PackageManager.PackageInfo.GetAllRegisteredPackages())
|
||||
{
|
||||
if (!string.IsNullOrEmpty(package.resolvedPath))
|
||||
{
|
||||
roots.Add(Path.GetFullPath(package.resolvedPath));
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
private static void WriteConfigAsset(RuntimePipelineConfig sourceConfig)
|
||||
{
|
||||
var folder = Path.GetDirectoryName(ConfigAssetPath).Replace('\\', '/');
|
||||
CreateFolderRecursive(folder);
|
||||
|
||||
// No DeleteGeneratedAssetIfPresent() call here: OnPreprocessBuild already purged both
|
||||
// paths before any of this method's caller ran, so this path is guaranteed clear.
|
||||
|
||||
var baked = ScriptableObject.CreateInstance<RuntimePipelineConfig>();
|
||||
baked.enableInBuilds = sourceConfig.enableInBuilds;
|
||||
baked.port = sourceConfig.port;
|
||||
baked.requestTimeoutMs = sourceConfig.requestTimeoutMs;
|
||||
baked.enableAuditLogging = sourceConfig.enableAuditLogging;
|
||||
baked.autoStart = sourceConfig.autoStart;
|
||||
baked.maxWorkItemsPerFrame = sourceConfig.maxWorkItemsPerFrame;
|
||||
AssetDatabase.CreateAsset(baked, ConfigAssetPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
|
||||
private static void WriteBuildInfoAsset(string pushPublicKey)
|
||||
{
|
||||
var folder = Path.GetDirectoryName(BuildInfoAssetPath).Replace('\\', '/');
|
||||
CreateFolderRecursive(folder);
|
||||
|
||||
// No DeleteGeneratedAssetIfPresent() call here: OnPreprocessBuild already purged both
|
||||
// paths before any of this method's caller ran, so this path is guaranteed clear.
|
||||
|
||||
var buildInfo = ScriptableObject.CreateInstance<RuntimePipelineBuildInfo>();
|
||||
buildInfo.allowedReloadRoots = CollectProjectRoots();
|
||||
buildInfo.pushPublicKey = pushPublicKey ?? "";
|
||||
AssetDatabase.CreateAsset(buildInfo, BuildInfoAssetPath);
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete both transient build-only assets if either is present, however they got there —
|
||||
/// baked by a previous build (normal cleanup) or left behind by one that never reached
|
||||
/// OnPostprocessBuild (crash, force-quit, cancelled build).
|
||||
/// </summary>
|
||||
private static void DeleteGeneratedAssetsIfPresent()
|
||||
{
|
||||
DeleteGeneratedAssetIfPresent(ConfigAssetPath);
|
||||
DeleteGeneratedAssetIfPresent(BuildInfoAssetPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Untyped load + a raw file-existence fallback, deliberately not a typed
|
||||
/// AssetDatabase.LoadAssetAtPath<T>: a typed load returns null — leaving a stale file
|
||||
/// untouched on disk, inside Resources, where a Player build would still package it — for
|
||||
/// any asset whose script reference broke after a package upgrade, or that was only
|
||||
/// partially written by the very crash that left it behind. Both are exactly the kind of
|
||||
/// leftover this cleanup exists to catch.
|
||||
/// </summary>
|
||||
private static void DeleteGeneratedAssetIfPresent(string assetPath)
|
||||
{
|
||||
if (AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(assetPath) != null || File.Exists(assetPath))
|
||||
AssetDatabase.DeleteAsset(assetPath);
|
||||
}
|
||||
|
||||
private static void CreateFolderRecursive(string folder)
|
||||
{
|
||||
var parts = folder.Split('/');
|
||||
var current = parts[0];
|
||||
for (var i = 1; i < parts.Length; i++)
|
||||
{
|
||||
var next = current + "/" + parts[i];
|
||||
if (!AssetDatabase.IsValidFolder(next))
|
||||
AssetDatabase.CreateFolder(current, parts[i]);
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
// Relative location of the bundled Roslyn DLLs + their integrity manifest within the package.
|
||||
private const string CodeAnalysisRelDir = "Runtime/Plugins/CodeAnalysis";
|
||||
private const string ChecksumsFileName = "CHECKSUMS";
|
||||
|
||||
/// <summary>
|
||||
/// Verify the bundled Roslyn DLLs against the committed CHECKSUMS manifest, locating them
|
||||
/// relative to this package on disk. Throws <see cref="BuildFailedException"/> on any
|
||||
/// mismatch so a tampered/swapped DLL cannot be built into a player.
|
||||
/// </summary>
|
||||
public static void VerifyBundledChecksums()
|
||||
{
|
||||
if (!TryGetBundledCodeAnalysisPaths(out var codeAnalysisDir, out var checksumsPath))
|
||||
{
|
||||
throw new BuildFailedException(
|
||||
"Pipeline: could not locate the com.unity.pipeline package on disk to verify " +
|
||||
"bundled Roslyn DLL integrity. Aborting build.");
|
||||
}
|
||||
|
||||
var error = VerifyChecksums(codeAnalysisDir, checksumsPath);
|
||||
if (error != null)
|
||||
{
|
||||
throw new BuildFailedException($"Pipeline: bundled Roslyn DLL integrity check failed. {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the bundled Roslyn DLL folder and its CHECKSUMS manifest from this package's
|
||||
/// location on disk. Returns false when the package cannot be located, in which case both
|
||||
/// out parameters are null.
|
||||
/// </summary>
|
||||
/// <param name="codeAnalysisDir">Directory holding the bundled Roslyn DLLs.</param>
|
||||
/// <param name="checksumsPath">Path to the CHECKSUMS manifest inside that directory.</param>
|
||||
/// <returns>True when the package was located and both paths were resolved.</returns>
|
||||
public static bool TryGetBundledCodeAnalysisPaths(out string codeAnalysisDir, out string checksumsPath)
|
||||
{
|
||||
codeAnalysisDir = null;
|
||||
checksumsPath = null;
|
||||
|
||||
var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssembly(
|
||||
typeof(PipelineRuntimeBuildProcessor).Assembly);
|
||||
|
||||
if (packageInfo == null || string.IsNullOrEmpty(packageInfo.resolvedPath))
|
||||
return false;
|
||||
|
||||
codeAnalysisDir = Path.Combine(packageInfo.resolvedPath, CodeAnalysisRelDir);
|
||||
checksumsPath = Path.Combine(codeAnalysisDir, ChecksumsFileName);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core, side-effect-free integrity check (so it is directly unit-testable). Returns null
|
||||
/// when every DLL listed in <paramref name="checksumsPath"/> exists under
|
||||
/// <paramref name="codeAnalysisDir"/> with a matching SHA-256 and no unlisted DLL is present;
|
||||
/// otherwise returns a human-readable error describing the first problem found.
|
||||
/// </summary>
|
||||
/// <param name="codeAnalysisDir">Directory containing the bundled Roslyn DLLs.</param>
|
||||
/// <param name="checksumsPath">Path to the CHECKSUMS manifest.</param>
|
||||
/// <returns>Null if every DLL matches; otherwise a human-readable description of the first problem found.</returns>
|
||||
public static string VerifyChecksums(string codeAnalysisDir, string checksumsPath)
|
||||
{
|
||||
if (!Directory.Exists(codeAnalysisDir))
|
||||
return $"DLL directory not found: {codeAnalysisDir}";
|
||||
if (!File.Exists(checksumsPath))
|
||||
return $"CHECKSUMS manifest not found: {checksumsPath}";
|
||||
|
||||
var expected = ParseChecksums(checksumsPath);
|
||||
if (expected.Count == 0)
|
||||
return $"CHECKSUMS manifest has no entries: {checksumsPath}";
|
||||
|
||||
// Every listed DLL must exist and match.
|
||||
foreach (var entry in expected)
|
||||
{
|
||||
var dllPath = Path.Combine(codeAnalysisDir, entry.Key);
|
||||
if (!File.Exists(dllPath))
|
||||
return $"listed DLL is missing: {entry.Key}";
|
||||
|
||||
var actual = ComputeSha256(dllPath);
|
||||
if (!string.Equals(actual, entry.Value, StringComparison.OrdinalIgnoreCase))
|
||||
return $"hash mismatch for {entry.Key} (expected {entry.Value}, got {actual})";
|
||||
}
|
||||
|
||||
// No unlisted DLL may sit alongside them (guards against an injected extra assembly).
|
||||
foreach (var dllPath in Directory.GetFiles(codeAnalysisDir, "*.dll"))
|
||||
{
|
||||
var name = Path.GetFileName(dllPath);
|
||||
if (!expected.ContainsKey(name))
|
||||
return $"unexpected DLL not listed in CHECKSUMS: {name}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>SHA-256 of a file as a lowercase hex string.</summary>
|
||||
/// <param name="filePath">The file to hash.</param>
|
||||
/// <returns>The lowercase hex-encoded SHA-256 hash.</returns>
|
||||
public static string ComputeSha256(string filePath)
|
||||
{
|
||||
using (var sha = SHA256.Create())
|
||||
using (var stream = File.OpenRead(filePath))
|
||||
{
|
||||
var hash = sha.ComputeHash(stream);
|
||||
var sb = new StringBuilder(hash.Length * 2);
|
||||
foreach (var b in hash)
|
||||
sb.Append(b.ToString("x2"));
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse "<sha256> <filename> # comment" lines, skipping blanks and '#' comment lines.
|
||||
private static Dictionary<string, string> ParseChecksums(string checksumsPath)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var raw in File.ReadAllLines(checksumsPath))
|
||||
{
|
||||
var line = raw.Trim();
|
||||
if (line.Length == 0 || line.StartsWith("#"))
|
||||
continue;
|
||||
|
||||
var tokens = line.Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (tokens.Length < 2)
|
||||
continue;
|
||||
|
||||
result[tokens[1]] = tokens[0];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 50df0b5c45692fb4a81122ca6eec4709
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,51 @@
|
||||
using UnityEditor;
|
||||
|
||||
namespace Unity.Pipeline.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// <para>Drop-in replacements for <see cref="EditorUtility"/>'s progress-bar calls that ALSO make
|
||||
/// the progress visible to a connected CLI over <c>GET /api/progress</c> (CLI-488).</para>
|
||||
/// <para><c>EditorUtility.DisplayProgressBar</c> has no public getter and cannot be observed from
|
||||
/// the pipeline server, so existing code keeps its Editor progress dialog but stays invisible
|
||||
/// to the terminal. Swapping the call site to this class keeps the exact same Editor behavior
|
||||
/// and adds the CLI reporting — including while the main thread is blocked inside the task,
|
||||
/// which is when the dialog (and the terminal bar) matter most:</para>
|
||||
/// <code>
|
||||
/// CliEditorProgress.DisplayProgressBar("Generating World", $"Processing {i}/{n}", (float)i / n);
|
||||
/// // …
|
||||
/// CliEditorProgress.ClearProgressBar();
|
||||
/// </code>
|
||||
/// <para>Code that can take a package dependency but runs headless (batchmode) can call
|
||||
/// <see cref="CliProgress.Report"/> directly instead.</para>
|
||||
/// </summary>
|
||||
public static class CliEditorProgress
|
||||
{
|
||||
/// <summary>Same as <see cref="EditorUtility.DisplayProgressBar"/>, plus CLI reporting.</summary>
|
||||
/// <param name="title">Short title for the operation.</param>
|
||||
/// <param name="info">Detail text (e.g. current step).</param>
|
||||
/// <param name="progress">Progress in [0, 1].</param>
|
||||
public static void DisplayProgressBar(string title, string info, float progress)
|
||||
{
|
||||
CliProgress.Report(title, info, progress: progress);
|
||||
EditorUtility.DisplayProgressBar(title, info, progress);
|
||||
}
|
||||
|
||||
/// <summary>Same as <see cref="EditorUtility.DisplayCancelableProgressBar"/>, plus CLI reporting.</summary>
|
||||
/// <param name="title">Short title for the operation.</param>
|
||||
/// <param name="info">Detail text (e.g. current step).</param>
|
||||
/// <param name="progress">Progress in [0, 1].</param>
|
||||
/// <returns>True if the user clicked Cancel.</returns>
|
||||
public static bool DisplayCancelableProgressBar(string title, string info, float progress)
|
||||
{
|
||||
CliProgress.Report(title, info, progress: progress);
|
||||
return EditorUtility.DisplayCancelableProgressBar(title, info, progress);
|
||||
}
|
||||
|
||||
/// <summary>Same as <see cref="EditorUtility.ClearProgressBar"/>, plus clearing the CLI report.</summary>
|
||||
public static void ClearProgressBar()
|
||||
{
|
||||
CliProgress.Clear();
|
||||
EditorUtility.ClearProgressBar();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bc6ed9f7b4ea400f8ed4508d6e2b761b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetVersion: 1
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e52bfc361c2ed7846a3c4c69b24828c5
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 881bcacc155b44229761a2e2b2917fae
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+427
@@ -0,0 +1,427 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Editor.Commands.GameObjects;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// Group A of CLI-214: author <see cref="AnimationClip"/> assets and their float curves.
|
||||
///
|
||||
/// These sit on the CLI-190 authoring foundation, so:
|
||||
/// - every agent-supplied asset path is funnelled through <see cref="ProjectPaths.Resolve"/>
|
||||
/// (sandboxed to the authoring root),
|
||||
/// - an existing clip is addressed with an <see cref="ObjectRef"/> resolved by
|
||||
/// <see cref="ObjectResolver"/> and re-confined to the authoring root,
|
||||
/// - destructive/overwriting operations require an explicit <c>confirm</c> argument and every
|
||||
/// command supports <c>dry_run</c>.
|
||||
///
|
||||
/// NOTE: AnimationClip sub-object edits (curves, clip settings) are NOT covered by Unity's Undo,
|
||||
/// so changes are persisted with <see cref="EditorUtility.SetDirty"/> + <see cref="AssetDatabase.SaveAssets"/>
|
||||
/// rather than wrapped in an <see cref="AuthoringUndoScope"/>. Only float curves are supported in
|
||||
/// v1 (object-reference / PPtr curves are out of scope).
|
||||
/// </summary>
|
||||
static class AnimationClipCommands
|
||||
{
|
||||
[CliCommand("create_animation_clip",
|
||||
"Create an empty .anim AnimationClip asset under the authoring root, with an optional frame rate and loop flag.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "animation/clip" })]
|
||||
public static AuthoringResult CreateAnimationClip(
|
||||
[CliArg("path", "Asset path ending in .anim, relative to the authoring root. The Assets/ prefix is optional.", Required = true)] string path,
|
||||
[CliArg("frameRate", "Sampling frame rate of the clip (default 60).")] float frameRate = 60f,
|
||||
[CliArg("loop", "If true, set the clip's loop-time flag in its AnimationClipSettings (default false).")] bool loop = false,
|
||||
[CliArg("confirm", "Required (true) only when overwriting an existing asset at the path.")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, validate inputs and report what would be created without writing anything.")] bool dryRun = false)
|
||||
{
|
||||
var normalized = ProjectPaths.Resolve(path, out var error);
|
||||
if (normalized == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
if (!string.Equals(Path.GetExtension(normalized), ".anim", StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException($"Animation clip path '{normalized}' must end in .anim.");
|
||||
|
||||
if (frameRate <= 0f)
|
||||
throw new ArgumentException($"frameRate must be positive (got {frameRate}).");
|
||||
|
||||
var exists = AssetDatabase.LoadMainAssetAtPath(normalized) != null;
|
||||
if (exists && !confirm)
|
||||
throw new ArgumentException($"An asset already exists at '{normalized}'. Pass confirm=true to overwrite it.");
|
||||
|
||||
if (dryRun)
|
||||
return new AuthoringResult { AssetPath = normalized, Type = nameof(AnimationClip) };
|
||||
|
||||
EnsureParentFolder(normalized);
|
||||
|
||||
var clip = new AnimationClip { frameRate = frameRate };
|
||||
|
||||
var settings = AnimationUtility.GetAnimationClipSettings(clip);
|
||||
settings.loopTime = loop;
|
||||
AnimationUtility.SetAnimationClipSettings(clip, settings);
|
||||
|
||||
// CreateAsset does not reliably overwrite, so remove an existing asset first (the confirm
|
||||
// guard above gates that one already exists).
|
||||
if (exists)
|
||||
AssetDatabase.DeleteAsset(normalized);
|
||||
|
||||
AssetDatabase.CreateAsset(clip, normalized);
|
||||
EditorUtility.SetDirty(clip);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.ImportAsset(normalized);
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = nameof(AnimationClip) };
|
||||
result.AssetPath = normalized;
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("set_animation_curve",
|
||||
"Add or replace a single float curve binding on an AnimationClip (via AnimationUtility.SetEditorCurve). " +
|
||||
"Replacing an existing binding overwrites it rather than duplicating.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "animation/clip" })]
|
||||
public static SetAnimationCurveResult SetAnimationCurve(
|
||||
[CliArg("clip", "Reference to the AnimationClip to edit (path / guid / globalId).", Required = true)] ObjectRef clip,
|
||||
[CliArg("path", "GameObject path relative to the animated root the property lives on. Empty string (default) targets the root.")] string path = "",
|
||||
[CliArg("type", "Component type the property lives on, e.g. \"Transform\", \"UnityEngine.Light\". Resolved via the component TypeResolver.", Required = true)] string type = null,
|
||||
[CliArg("property", "Curve property name, e.g. \"m_LocalPosition.x\", \"m_LocalScale.y\", \"localEulerAnglesRaw.z\".", Required = true)] string property = null,
|
||||
[CliArg("keys", "Keyframes: [{ time, value, inTangent?, outTangent?, weightedMode?: \"None\"|\"In\"|\"Out\"|\"Both\" }]. Omitted tangents default to 0 (flat); this is NOT Unity's Auto tangent mode.", Required = true)] JArray keys = null,
|
||||
[CliArg("dry_run", "If true, validate type/property/keys without writing the curve.")] bool dryRun = false)
|
||||
{
|
||||
var (clipAsset, clipPath) = ResolveClip(clip);
|
||||
var componentType = ResolveCurveType(type);
|
||||
if (string.IsNullOrWhiteSpace(property))
|
||||
throw new ArgumentException("property is required.");
|
||||
|
||||
var bindingPath = path ?? string.Empty;
|
||||
var curve = BuildCurve(keys, out var keyCount);
|
||||
|
||||
var result = new SetAnimationCurveResult
|
||||
{
|
||||
AssetPath = clipPath,
|
||||
Type = nameof(AnimationClip),
|
||||
Binding = new CurveBinding { Path = bindingPath, Type = componentType.Name, Property = property },
|
||||
KeyCount = keyCount
|
||||
};
|
||||
|
||||
if (dryRun)
|
||||
return result;
|
||||
|
||||
var binding = EditorCurveBinding.FloatCurve(bindingPath, componentType, property);
|
||||
AnimationUtility.SetEditorCurve(clipAsset, binding, curve);
|
||||
|
||||
EditorUtility.SetDirty(clipAsset);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
var described = ObjectResolver.Describe(clipAsset);
|
||||
if (described != null)
|
||||
{
|
||||
result.Guid = described.Guid;
|
||||
result.FileId = described.FileId;
|
||||
result.GlobalId = described.GlobalId;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("get_animation_clip",
|
||||
"Read an AnimationClip's metadata and all float curve bindings (optionally with keyframes).",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "animation/clip" })]
|
||||
public static AnimationClipInfo GetAnimationClip(
|
||||
[CliArg("clip", "Reference to the AnimationClip to read (path / guid / globalId).", Required = true)] ObjectRef clip,
|
||||
[CliArg("includeKeys", "If true, include each binding's keyframes (default false).")] bool includeKeys = false)
|
||||
{
|
||||
var (clipAsset, clipPath) = ResolveClip(clip);
|
||||
|
||||
var settings = AnimationUtility.GetAnimationClipSettings(clipAsset);
|
||||
var info = new AnimationClipInfo
|
||||
{
|
||||
AssetPath = clipPath,
|
||||
FrameRate = clipAsset.frameRate,
|
||||
Length = clipAsset.length,
|
||||
Loop = settings.loopTime
|
||||
};
|
||||
|
||||
foreach (var binding in AnimationUtility.GetCurveBindings(clipAsset))
|
||||
{
|
||||
var curve = AnimationUtility.GetEditorCurve(clipAsset, binding);
|
||||
var keyCount = curve?.length ?? 0;
|
||||
|
||||
var entry = new CurveBindingInfo
|
||||
{
|
||||
Path = binding.path,
|
||||
Type = binding.type != null ? binding.type.Name : null,
|
||||
Property = binding.propertyName,
|
||||
KeyCount = keyCount
|
||||
};
|
||||
|
||||
if (includeKeys && curve != null)
|
||||
{
|
||||
entry.Keys = new List<KeyframeInfo>(keyCount);
|
||||
foreach (var key in curve.keys)
|
||||
{
|
||||
entry.Keys.Add(new KeyframeInfo
|
||||
{
|
||||
Time = key.time,
|
||||
Value = key.value,
|
||||
InTangent = key.inTangent,
|
||||
OutTangent = key.outTangent
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
info.Bindings.Add(entry);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
[CliCommand("remove_animation_curve",
|
||||
"Remove a float curve binding from an AnimationClip (SetEditorCurve(clip, binding, null)). Destructive: requires confirm=true.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "animation/clip" })]
|
||||
public static SetAnimationCurveResult RemoveAnimationCurve(
|
||||
[CliArg("clip", "Reference to the AnimationClip to edit (path / guid / globalId).", Required = true)] ObjectRef clip,
|
||||
[CliArg("path", "GameObject path relative to the animated root the binding lives on. Empty string (default) targets the root.")] string path = "",
|
||||
[CliArg("type", "Component type of the binding to remove, e.g. \"Transform\". Resolved via the component TypeResolver.", Required = true)] string type = null,
|
||||
[CliArg("property", "Curve property name to remove, e.g. \"m_LocalPosition.x\".", Required = true)] string property = null,
|
||||
[CliArg("confirm", "Must be true to actually remove the binding (destructive guard).")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, report the binding that would be removed without removing it.")] bool dryRun = false)
|
||||
{
|
||||
var (clipAsset, clipPath) = ResolveClip(clip);
|
||||
var componentType = ResolveCurveType(type);
|
||||
if (string.IsNullOrWhiteSpace(property))
|
||||
throw new ArgumentException("property is required.");
|
||||
|
||||
var bindingPath = path ?? string.Empty;
|
||||
var binding = EditorCurveBinding.FloatCurve(bindingPath, componentType, property);
|
||||
var existing = AnimationUtility.GetEditorCurve(clipAsset, binding);
|
||||
if (existing == null)
|
||||
throw new ArgumentException(
|
||||
$"No curve binding for path='{bindingPath}', type='{componentType.Name}', property='{property}' on '{clipPath}'.");
|
||||
|
||||
var keyCount = existing.length;
|
||||
var result = new SetAnimationCurveResult
|
||||
{
|
||||
AssetPath = clipPath,
|
||||
Type = nameof(AnimationClip),
|
||||
Binding = new CurveBinding { Path = bindingPath, Type = componentType.Name, Property = property },
|
||||
KeyCount = keyCount
|
||||
};
|
||||
|
||||
if (dryRun)
|
||||
return result;
|
||||
|
||||
if (!confirm)
|
||||
throw new ArgumentException(
|
||||
$"Refusing to remove curve '{property}' from '{clipPath}'. Pass confirm=true (destructive, not undoable via Unity's Undo).");
|
||||
|
||||
AnimationUtility.SetEditorCurve(clipAsset, binding, null);
|
||||
EditorUtility.SetDirty(clipAsset);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a clip handle to a loaded <see cref="AnimationClip"/> and its sandbox-confined asset
|
||||
/// path, rejecting handles that don't point at an on-disk AnimationClip or that escape the root.
|
||||
/// </summary>
|
||||
private static (AnimationClip clip, string path) ResolveClip(ObjectRef clip)
|
||||
{
|
||||
if (clip == null || clip.IsEmpty)
|
||||
throw new ArgumentException("clip is required.");
|
||||
|
||||
if (!ObjectResolver.TryResolve(clip, out var obj, out var error))
|
||||
throw new ArgumentException(error);
|
||||
|
||||
if (!(obj is AnimationClip clipAsset))
|
||||
throw new ArgumentException($"Reference '{clip}' resolved to a {obj.GetType().Name}, not an AnimationClip.");
|
||||
|
||||
var assetPath = AssetDatabase.GetAssetPath(clipAsset);
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
throw new ArgumentException($"Reference '{clip}' does not point at an on-disk AnimationClip.");
|
||||
|
||||
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
|
||||
if (confined == null)
|
||||
throw new ArgumentException(
|
||||
$"Clip '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
|
||||
|
||||
return (clipAsset, confined);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a curve-binding component type. Reuses the component <see cref="TypeResolver"/>,
|
||||
/// which only accepts <see cref="Component"/> subclasses (curves bind to component properties).
|
||||
/// </summary>
|
||||
private static Type ResolveCurveType(string type)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(type))
|
||||
throw new ArgumentException("type is required.");
|
||||
|
||||
var resolved = TypeResolver.ResolveComponentType(type);
|
||||
if (resolved == null)
|
||||
throw new ArgumentException(
|
||||
$"Could not resolve component type '{type}'. Use a short name (e.g. Transform) or a fully-qualified name (e.g. UnityEngine.Light).");
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/// <summary>Build an <see cref="AnimationCurve"/> from the agent-supplied keyframe array.</summary>
|
||||
private static AnimationCurve BuildCurve(JArray keys, out int keyCount)
|
||||
{
|
||||
if (keys == null || keys.Count == 0)
|
||||
throw new ArgumentException("keys must be a non-empty array of { time, value, ... }.");
|
||||
|
||||
var keyframes = new List<Keyframe>(keys.Count);
|
||||
foreach (var token in keys)
|
||||
{
|
||||
if (!(token is JObject keyObj))
|
||||
throw new ArgumentException("Each key must be an object: { time, value, inTangent?, outTangent?, weightedMode? }.");
|
||||
|
||||
if (keyObj["time"] == null || keyObj["value"] == null)
|
||||
throw new ArgumentException("Each key requires a 'time' and a 'value'.");
|
||||
|
||||
var time = keyObj["time"].ToObject<float>();
|
||||
var value = keyObj["value"].ToObject<float>();
|
||||
var inTangent = keyObj["inTangent"]?.ToObject<float>() ?? 0f;
|
||||
var outTangent = keyObj["outTangent"]?.ToObject<float>() ?? 0f;
|
||||
|
||||
var keyframe = new Keyframe(time, value, inTangent, outTangent);
|
||||
|
||||
var weightedToken = keyObj["weightedMode"];
|
||||
if (weightedToken != null && weightedToken.Type != JTokenType.Null)
|
||||
{
|
||||
var name = weightedToken.ToObject<string>();
|
||||
if (!Enum.TryParse<WeightedMode>(name, ignoreCase: true, out var weightedMode))
|
||||
throw new ArgumentException($"Unknown weightedMode '{name}'. Use None | In | Out | Both.");
|
||||
keyframe.weightedMode = weightedMode;
|
||||
}
|
||||
|
||||
keyframes.Add(keyframe);
|
||||
}
|
||||
|
||||
keyCount = keyframes.Count;
|
||||
return new AnimationCurve(keyframes.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Create the asset's parent folder chain if it does not yet exist.</summary>
|
||||
private static void EnsureParentFolder(string assetPath)
|
||||
{
|
||||
var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent))
|
||||
return;
|
||||
|
||||
CreateFolderRecursive(parent);
|
||||
}
|
||||
|
||||
private static void CreateFolderRecursive(string assetsPath)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(assetsPath))
|
||||
return;
|
||||
|
||||
var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/');
|
||||
var name = Path.GetFileName(assetsPath);
|
||||
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
|
||||
throw new ArgumentException($"Invalid folder path '{assetsPath}'.");
|
||||
|
||||
if (!AssetDatabase.IsValidFolder(parent))
|
||||
CreateFolderRecursive(parent);
|
||||
|
||||
AssetDatabase.CreateFolder(parent, name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <c>set_animation_curve</c> / <c>remove_animation_curve</c>: the clip identity extended
|
||||
/// with the affected binding and its key count.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
class SetAnimationCurveResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("binding")]
|
||||
public CurveBinding Binding { get; set; }
|
||||
|
||||
[JsonProperty("keyCount")]
|
||||
public int KeyCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A curve binding's address: GameObject path, component type, and property name.</summary>
|
||||
[Serializable]
|
||||
class CurveBinding
|
||||
{
|
||||
[JsonProperty("path")]
|
||||
public string Path { get; set; }
|
||||
|
||||
[JsonProperty("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
[JsonProperty("property")]
|
||||
public string Property { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Result of <c>get_animation_clip</c>: clip metadata and its curve bindings.</summary>
|
||||
[Serializable]
|
||||
class AnimationClipInfo
|
||||
{
|
||||
[JsonProperty("assetPath")]
|
||||
public string AssetPath { get; set; }
|
||||
|
||||
[JsonProperty("frameRate")]
|
||||
public float FrameRate { get; set; }
|
||||
|
||||
[JsonProperty("length")]
|
||||
public float Length { get; set; }
|
||||
|
||||
[JsonProperty("loop")]
|
||||
public bool Loop { get; set; }
|
||||
|
||||
[JsonProperty("bindings")]
|
||||
public List<CurveBindingInfo> Bindings { get; set; } = new List<CurveBindingInfo>();
|
||||
}
|
||||
|
||||
/// <summary>A single curve binding in <see cref="AnimationClipInfo"/>, optionally with its keys.</summary>
|
||||
[Serializable]
|
||||
class CurveBindingInfo
|
||||
{
|
||||
[JsonProperty("path")]
|
||||
public string Path { get; set; }
|
||||
|
||||
[JsonProperty("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
[JsonProperty("property")]
|
||||
public string Property { get; set; }
|
||||
|
||||
[JsonProperty("keyCount")]
|
||||
public int KeyCount { get; set; }
|
||||
|
||||
[JsonProperty("keys", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public List<KeyframeInfo> Keys { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A single keyframe in a curve binding.</summary>
|
||||
[Serializable]
|
||||
class KeyframeInfo
|
||||
{
|
||||
[JsonProperty("time")]
|
||||
public float Time { get; set; }
|
||||
|
||||
[JsonProperty("value")]
|
||||
public float Value { get; set; }
|
||||
|
||||
[JsonProperty("inTangent")]
|
||||
public float InTangent { get; set; }
|
||||
|
||||
[JsonProperty("outTangent")]
|
||||
public float OutTangent { get; set; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 764503eaf79a41668748014af111eaf2
|
||||
+891
@@ -0,0 +1,891 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Animations;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// Group B of CLI-214: author <see cref="AnimatorController"/> assets — layers, parameters, states
|
||||
/// and transitions. Built on <c>UnityEditor.Animations</c>, which is part of the Editor (no extra
|
||||
/// package), so the types are referenced directly.
|
||||
///
|
||||
/// Like the animation-clip commands, paths/handles are sandbox-confined via
|
||||
/// <see cref="ProjectPaths"/> / <see cref="ObjectResolver"/>, and every command supports
|
||||
/// <c>dry_run</c>. Some AnimatorController API calls register Undo internally, but sub-object edits
|
||||
/// are not reliably undoable, so changes are persisted with <see cref="EditorUtility.SetDirty"/> +
|
||||
/// <see cref="AssetDatabase.SaveAssets"/>.
|
||||
///
|
||||
/// Out of scope (v1): BlendTree authoring (assigning an EXISTING BlendTree as a state motion is
|
||||
/// allowed), StateMachineBehaviours, sub-state machines, and humanoid/avatar configuration.
|
||||
/// </summary>
|
||||
static class AnimatorControllerCommands
|
||||
{
|
||||
[CliCommand("create_animator_controller",
|
||||
"Create an .controller AnimatorController asset (with a default Base Layer) under the authoring root.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "animation/animator" })]
|
||||
public static AuthoringResult CreateAnimatorController(
|
||||
[CliArg("path", "Asset path ending in .controller, relative to the authoring root. The Assets/ prefix is optional.", Required = true)] string path,
|
||||
[CliArg("confirm", "Required (true) only when overwriting an existing asset at the path.")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, validate inputs and report what would be created without writing anything.")] bool dryRun = false)
|
||||
{
|
||||
var normalized = ProjectPaths.Resolve(path, out var error);
|
||||
if (normalized == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
if (!string.Equals(Path.GetExtension(normalized), ".controller", StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException($"Animator controller path '{normalized}' must end in .controller.");
|
||||
|
||||
var exists = AssetDatabase.LoadMainAssetAtPath(normalized) != null;
|
||||
if (exists && !confirm)
|
||||
throw new ArgumentException($"An asset already exists at '{normalized}'. Pass confirm=true to overwrite it.");
|
||||
|
||||
if (dryRun)
|
||||
return new AuthoringResult { AssetPath = normalized, Type = nameof(AnimatorController) };
|
||||
|
||||
EnsureParentFolder(normalized);
|
||||
|
||||
if (exists)
|
||||
AssetDatabase.DeleteAsset(normalized);
|
||||
|
||||
// CreateAnimatorControllerAtPath creates the asset on disk WITH a default "Base Layer".
|
||||
var controller = AnimatorController.CreateAnimatorControllerAtPath(normalized);
|
||||
EditorUtility.SetDirty(controller);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = nameof(AnimatorController) };
|
||||
result.AssetPath = normalized;
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("add_animator_parameter",
|
||||
"Add a parameter (Float | Int | Bool | Trigger) to an AnimatorController. A duplicate name returns code 'duplicate_parameter'.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "animation/animator" })]
|
||||
public static object AddAnimatorParameter(
|
||||
[CliArg("controller", "Reference to the AnimatorController to edit (path / guid / globalId).", Required = true)] ObjectRef controller,
|
||||
[CliArg("name", "Parameter name.", Required = true)] string name,
|
||||
[CliArg("type", "Parameter type: Float | Int | Bool | Trigger.", Required = true)] string type = null,
|
||||
[CliArg("defaultValue", "Default value for Float/Int/Bool (ignored for Trigger).")] JToken defaultValue = null,
|
||||
[CliArg("dry_run", "If true, validate inputs without writing the parameter.")] bool dryRun = false)
|
||||
{
|
||||
var (ctrl, ctrlPath) = ResolveController(controller);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("name is required.");
|
||||
|
||||
var paramType = ParseParameterType(type);
|
||||
|
||||
if (ctrl.parameters.Any(p => p.name == name))
|
||||
return ErrorResult.PackageStyle("duplicate_parameter", $"A parameter named '{name}' already exists on '{ctrlPath}'.");
|
||||
|
||||
// Resolve (and validate) the default value up front so dry_run also fails on a bad value.
|
||||
float defFloat = 0f;
|
||||
int defInt = 0;
|
||||
bool defBool = false;
|
||||
switch (paramType)
|
||||
{
|
||||
case AnimatorControllerParameterType.Float:
|
||||
if (defaultValue != null && defaultValue.Type != JTokenType.Null) defFloat = defaultValue.ToObject<float>();
|
||||
break;
|
||||
case AnimatorControllerParameterType.Int:
|
||||
if (defaultValue != null && defaultValue.Type != JTokenType.Null) defInt = defaultValue.ToObject<int>();
|
||||
break;
|
||||
case AnimatorControllerParameterType.Bool:
|
||||
if (defaultValue != null && defaultValue.Type != JTokenType.Null) defBool = defaultValue.ToObject<bool>();
|
||||
break;
|
||||
}
|
||||
|
||||
var result = new AddParameterResult
|
||||
{
|
||||
AssetPath = ctrlPath,
|
||||
Type = nameof(AnimatorController),
|
||||
Parameter = new ParameterInfo
|
||||
{
|
||||
Name = name,
|
||||
ParamType = paramType.ToString(),
|
||||
DefaultValue = DefaultValueToken(paramType, defFloat, defInt, defBool)
|
||||
}
|
||||
};
|
||||
|
||||
if (dryRun)
|
||||
return result;
|
||||
|
||||
var parameter = new AnimatorControllerParameter
|
||||
{
|
||||
name = name,
|
||||
type = paramType,
|
||||
defaultFloat = defFloat,
|
||||
defaultInt = defInt,
|
||||
defaultBool = defBool
|
||||
};
|
||||
ctrl.AddParameter(parameter);
|
||||
|
||||
Persist(ctrl);
|
||||
FillIdentity(result, ctrl);
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("add_animator_layer",
|
||||
"Add a layer to an AnimatorController.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "animation/animator" })]
|
||||
public static object AddAnimatorLayer(
|
||||
[CliArg("controller", "Reference to the AnimatorController to edit (path / guid / globalId).", Required = true)] ObjectRef controller,
|
||||
[CliArg("name", "Layer name.", Required = true)] string name,
|
||||
[CliArg("weight", "Layer weight (default 1).")] float weight = 1f,
|
||||
[CliArg("blendingMode", "Blending mode: Override | Additive (default Override).")] string blendingMode = "Override",
|
||||
[CliArg("dry_run", "If true, validate inputs without writing the layer.")] bool dryRun = false)
|
||||
{
|
||||
var (ctrl, ctrlPath) = ResolveController(controller);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("name is required.");
|
||||
|
||||
if (!Enum.TryParse<AnimatorLayerBlendingMode>(blendingMode, ignoreCase: true, out var mode))
|
||||
throw new ArgumentException($"Unknown blendingMode '{blendingMode}'. Use Override | Additive.");
|
||||
|
||||
var index = ctrl.layers.Length;
|
||||
var result = new AddLayerResult
|
||||
{
|
||||
AssetPath = ctrlPath,
|
||||
Type = nameof(AnimatorController),
|
||||
Layer = new LayerSummary { Name = name, Index = index, Weight = weight }
|
||||
};
|
||||
|
||||
if (dryRun)
|
||||
return result;
|
||||
|
||||
// AddLayer(name) appends a layer with a fresh state machine. We then set its weight/blending
|
||||
// by writing the layer array back (AnimatorControllerLayer is a value-like wrapper, so the
|
||||
// edited copy must be reassigned).
|
||||
ctrl.AddLayer(name);
|
||||
var layers = ctrl.layers;
|
||||
layers[index].defaultWeight = weight;
|
||||
layers[index].blendingMode = mode;
|
||||
ctrl.layers = layers;
|
||||
|
||||
Persist(ctrl);
|
||||
FillIdentity(result, ctrl);
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("add_animator_state",
|
||||
"Add a state to a layer, optionally with a motion (AnimationClip or BlendTree) and as the layer default. " +
|
||||
"A layer name with no match returns code 'layer_not_found'.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "animation/animator" })]
|
||||
public static object AddAnimatorState(
|
||||
[CliArg("controller", "Reference to the AnimatorController to edit (path / guid / globalId).", Required = true)] ObjectRef controller,
|
||||
[CliArg("layer", "Layer index (int) or name (string). Default 0 (Base Layer).")] JToken layer = null,
|
||||
[CliArg("name", "State name.", Required = true)] string name = null,
|
||||
[CliArg("motion", "Optional AnimationClip or BlendTree asset to assign as the state's motion.")] ObjectRef motion = null,
|
||||
[CliArg("isDefault", "If true, set this state as the layer's default state.")] bool isDefault = false,
|
||||
[CliArg("position", "Optional [x, y] node position in the graph (cosmetic).")] JArray position = null,
|
||||
[CliArg("dry_run", "If true, validate inputs without writing the state.")] bool dryRun = false)
|
||||
{
|
||||
var (ctrl, ctrlPath) = ResolveController(controller);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("name is required.");
|
||||
|
||||
if (!TryResolveLayer(ctrl, layer, out var layerIndex, out var layerError))
|
||||
return ErrorResult.PackageStyle("layer_not_found", layerError);
|
||||
|
||||
// Resolve an optional motion. Only AnimationClip / Motion (BlendTree derives from Motion) are
|
||||
// valid. A handle that doesn't resolve to a Motion is a clear error.
|
||||
UnityEngine.Motion resolvedMotion = null;
|
||||
if (motion != null && !motion.IsEmpty)
|
||||
{
|
||||
if (!ObjectResolver.TryResolve(motion, out var motionObj, out var motionError))
|
||||
throw new ArgumentException($"Could not resolve motion: {motionError}");
|
||||
resolvedMotion = motionObj as UnityEngine.Motion;
|
||||
if (resolvedMotion == null)
|
||||
throw new ArgumentException($"Motion reference '{motion}' resolved to a {motionObj.GetType().Name}, not an AnimationClip or BlendTree.");
|
||||
|
||||
// Re-confine the motion to the authoring root: a restricted root must not be bypassed by
|
||||
// referencing an AnimationClip/BlendTree that lives outside the sandbox.
|
||||
ConfineToAuthoringRoot(resolvedMotion, "Motion", motion);
|
||||
}
|
||||
|
||||
Vector3? nodePosition = null;
|
||||
if (position != null && position.Count > 0)
|
||||
{
|
||||
if (position.Count < 2)
|
||||
throw new ArgumentException("position must be [x, y].");
|
||||
nodePosition = new Vector3(position[0].ToObject<float>(), position[1].ToObject<float>(), 0f);
|
||||
}
|
||||
|
||||
var result = new AddStateResult
|
||||
{
|
||||
AssetPath = ctrlPath,
|
||||
Type = nameof(AnimatorController),
|
||||
State = new StateSummary
|
||||
{
|
||||
Name = name,
|
||||
Layer = layerIndex,
|
||||
HasMotion = resolvedMotion != null,
|
||||
IsDefault = isDefault
|
||||
}
|
||||
};
|
||||
|
||||
if (dryRun)
|
||||
return result;
|
||||
|
||||
var stateMachine = ctrl.layers[layerIndex].stateMachine;
|
||||
var state = nodePosition.HasValue
|
||||
? stateMachine.AddState(name, nodePosition.Value)
|
||||
: stateMachine.AddState(name);
|
||||
|
||||
if (resolvedMotion != null)
|
||||
state.motion = resolvedMotion;
|
||||
|
||||
if (isDefault)
|
||||
stateMachine.defaultState = state;
|
||||
|
||||
Persist(ctrl);
|
||||
FillIdentity(result, ctrl);
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("add_animator_transition",
|
||||
"Add a transition between two states (or from AnyState/Entry, to Exit) on a layer, with optional conditions. " +
|
||||
"Validates that the states exist and each condition's parameter exists and its mode matches the parameter type.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "animation/animator" })]
|
||||
public static object AddAnimatorTransition(
|
||||
[CliArg("controller", "Reference to the AnimatorController to edit (path / guid / globalId).", Required = true)] ObjectRef controller,
|
||||
[CliArg("layer", "Layer index (int) or name (string). Default 0 (Base Layer).")] JToken layer = null,
|
||||
[CliArg("fromState", "Source state name, or the special \"AnyState\" / \"Entry\".", Required = true)] string fromState = null,
|
||||
[CliArg("toState", "Destination state name, or the special \"Exit\".", Required = true)] string toState = null,
|
||||
[CliArg("conditions", "Optional conditions: [{ parameter, mode: \"If\"|\"IfNot\"|\"Greater\"|\"Less\"|\"Equals\"|\"NotEqual\", threshold? }].")] JArray conditions = null,
|
||||
[CliArg("hasExitTime", "If true, the transition uses exit time (default false).")] bool hasExitTime = false,
|
||||
[CliArg("exitTime", "Normalized exit time (0..1) when hasExitTime is set.")] float exitTime = 0f,
|
||||
[CliArg("duration", "Transition duration in seconds (default 0.25).")] float duration = 0.25f,
|
||||
[CliArg("hasFixedDuration", "If true, duration is in seconds; otherwise normalized (default true).")] bool hasFixedDuration = true,
|
||||
[CliArg("dry_run", "If true, validate everything (states, parameters, mode/type) without writing the transition.")] bool dryRun = false)
|
||||
{
|
||||
var (ctrl, ctrlPath) = ResolveController(controller);
|
||||
if (string.IsNullOrWhiteSpace(fromState))
|
||||
throw new ArgumentException("fromState is required.");
|
||||
if (string.IsNullOrWhiteSpace(toState))
|
||||
throw new ArgumentException("toState is required.");
|
||||
|
||||
// exitTime is normalized (0..1) and only meaningful with hasExitTime; duration must be
|
||||
// non-negative. Reject out-of-range values up front (writing nothing) rather than letting
|
||||
// Unity clamp them unpredictably.
|
||||
if (hasExitTime && (exitTime < 0f || exitTime > 1f))
|
||||
throw new ArgumentException($"exitTime must be in [0, 1] when hasExitTime is set (got {exitTime}).");
|
||||
if (duration < 0f)
|
||||
throw new ArgumentException($"duration must be >= 0 (got {duration}).");
|
||||
|
||||
if (!TryResolveLayer(ctrl, layer, out var layerIndex, out var layerError))
|
||||
return ErrorResult.PackageStyle("layer_not_found", layerError);
|
||||
|
||||
var stateMachine = ctrl.layers[layerIndex].stateMachine;
|
||||
|
||||
const string AnyState = "AnyState";
|
||||
const string Entry = "Entry";
|
||||
const string Exit = "Exit";
|
||||
|
||||
var fromIsAny = string.Equals(fromState, AnyState, StringComparison.OrdinalIgnoreCase);
|
||||
var fromIsEntry = string.Equals(fromState, Entry, StringComparison.OrdinalIgnoreCase);
|
||||
var toIsExit = string.Equals(toState, Exit, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Resolve the source state (unless it's AnyState/Entry, which are nodes on the machine).
|
||||
AnimatorState fromAnimState = null;
|
||||
if (!fromIsAny && !fromIsEntry)
|
||||
{
|
||||
fromAnimState = FindState(stateMachine, fromState);
|
||||
if (fromAnimState == null)
|
||||
throw new ArgumentException($"Source state '{fromState}' not found in layer {layerIndex}.");
|
||||
}
|
||||
|
||||
// Resolve the destination state (unless it's Exit).
|
||||
AnimatorState toAnimState = null;
|
||||
if (!toIsExit)
|
||||
{
|
||||
toAnimState = FindState(stateMachine, toState);
|
||||
if (toAnimState == null)
|
||||
throw new ArgumentException($"Destination state '{toState}' not found in layer {layerIndex}.");
|
||||
}
|
||||
|
||||
// Entry transitions cannot carry conditions/exit-time the same way; restrict combinations the
|
||||
// engine forbids so an agent gets a clear error rather than a malformed transition.
|
||||
if (fromIsEntry && toIsExit)
|
||||
throw new ArgumentException("A transition from Entry directly to Exit is not supported.");
|
||||
|
||||
// Validate conditions BEFORE creating anything (so a bad condition writes nothing).
|
||||
var parsedConditions = ParseConditions(conditions, ctrl);
|
||||
|
||||
var result = new AddTransitionResult
|
||||
{
|
||||
AssetPath = ctrlPath,
|
||||
Type = nameof(AnimatorController),
|
||||
Transition = new TransitionSummary
|
||||
{
|
||||
From = fromState,
|
||||
To = toState,
|
||||
ConditionCount = parsedConditions.Count
|
||||
}
|
||||
};
|
||||
|
||||
if (dryRun)
|
||||
return result;
|
||||
|
||||
// Create the transition node matching the from/to kinds.
|
||||
AnimatorStateTransition stateTransition = null;
|
||||
AnimatorTransition entryTransition = null;
|
||||
|
||||
if (fromIsEntry)
|
||||
{
|
||||
// Entry -> state. Entry transitions are plain AnimatorTransition (no exit time/duration).
|
||||
entryTransition = stateMachine.AddEntryTransition(toAnimState);
|
||||
}
|
||||
else if (fromIsAny)
|
||||
{
|
||||
stateTransition = toIsExit ? null : stateMachine.AddAnyStateTransition(toAnimState);
|
||||
if (stateTransition == null)
|
||||
throw new ArgumentException("An AnyState transition to Exit is not supported.");
|
||||
}
|
||||
else
|
||||
{
|
||||
stateTransition = toIsExit
|
||||
? fromAnimState.AddExitTransition()
|
||||
: fromAnimState.AddTransition(toAnimState);
|
||||
}
|
||||
|
||||
if (stateTransition != null)
|
||||
{
|
||||
stateTransition.hasExitTime = hasExitTime;
|
||||
stateTransition.exitTime = exitTime;
|
||||
stateTransition.hasFixedDuration = hasFixedDuration;
|
||||
stateTransition.duration = duration;
|
||||
foreach (var c in parsedConditions)
|
||||
stateTransition.AddCondition(c.Mode, c.Threshold, c.Parameter);
|
||||
}
|
||||
else if (entryTransition != null)
|
||||
{
|
||||
foreach (var c in parsedConditions)
|
||||
entryTransition.AddCondition(c.Mode, c.Threshold, c.Parameter);
|
||||
}
|
||||
|
||||
Persist(ctrl);
|
||||
FillIdentity(result, ctrl);
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("get_animator_controller",
|
||||
"Read an AnimatorController's full structure: parameters, layers, states (with motion / default), and transitions (with conditions).",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "animation/animator" })]
|
||||
public static AnimatorControllerInfo GetAnimatorController(
|
||||
[CliArg("controller", "Reference to the AnimatorController to read (path / guid / globalId).", Required = true)] ObjectRef controller)
|
||||
{
|
||||
var (ctrl, ctrlPath) = ResolveController(controller);
|
||||
|
||||
var info = new AnimatorControllerInfo { AssetPath = ctrlPath };
|
||||
|
||||
foreach (var p in ctrl.parameters)
|
||||
{
|
||||
info.Parameters.Add(new ParameterInfo
|
||||
{
|
||||
Name = p.name,
|
||||
ParamType = p.type.ToString(),
|
||||
DefaultValue = DefaultValueToken(p.type, p.defaultFloat, p.defaultInt, p.defaultBool)
|
||||
});
|
||||
}
|
||||
|
||||
for (int i = 0; i < ctrl.layers.Length; i++)
|
||||
{
|
||||
var layer = ctrl.layers[i];
|
||||
var sm = layer.stateMachine;
|
||||
|
||||
var layerInfo = new LayerInfo
|
||||
{
|
||||
Name = layer.name,
|
||||
Index = i,
|
||||
DefaultState = sm != null && sm.defaultState != null ? sm.defaultState.name : null
|
||||
};
|
||||
|
||||
if (sm != null)
|
||||
{
|
||||
foreach (var childState in sm.states)
|
||||
{
|
||||
var state = childState.state;
|
||||
layerInfo.States.Add(new StateInfo
|
||||
{
|
||||
Name = state.name,
|
||||
Motion = state.motion != null ? state.motion.name : null,
|
||||
IsDefault = sm.defaultState == state
|
||||
});
|
||||
|
||||
foreach (var t in state.transitions)
|
||||
layerInfo.Transitions.Add(DescribeTransition(state.name, t));
|
||||
}
|
||||
|
||||
foreach (var t in sm.anyStateTransitions)
|
||||
layerInfo.Transitions.Add(DescribeTransition("AnyState", t));
|
||||
}
|
||||
|
||||
info.Layers.Add(layerInfo);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static TransitionInfo DescribeTransition(string from, AnimatorStateTransition t)
|
||||
{
|
||||
var to = t.isExit ? "Exit" : (t.destinationState != null ? t.destinationState.name : null);
|
||||
var ti = new TransitionInfo
|
||||
{
|
||||
From = from,
|
||||
To = to,
|
||||
HasExitTime = t.hasExitTime,
|
||||
Duration = t.duration
|
||||
};
|
||||
foreach (var c in t.conditions)
|
||||
{
|
||||
ti.Conditions.Add(new ConditionInfo
|
||||
{
|
||||
Parameter = c.parameter,
|
||||
Mode = c.mode.ToString(),
|
||||
Threshold = c.threshold
|
||||
});
|
||||
}
|
||||
return ti;
|
||||
}
|
||||
|
||||
private static AnimatorState FindState(AnimatorStateMachine sm, string name)
|
||||
{
|
||||
if (sm == null)
|
||||
return null;
|
||||
foreach (var childState in sm.states)
|
||||
{
|
||||
if (childState.state != null && childState.state.name == name)
|
||||
return childState.state;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private struct ParsedCondition
|
||||
{
|
||||
public string Parameter;
|
||||
public AnimatorConditionMode Mode;
|
||||
public float Threshold;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse and validate the conditions array against the controller's parameters: each parameter
|
||||
/// must exist and its type must be compatible with the condition mode (If/IfNot for Bool/Trigger;
|
||||
/// numeric Greater/Less/Equals/NotEqual for Float/Int). Throws (writing nothing) on any mismatch.
|
||||
/// </summary>
|
||||
private static List<ParsedCondition> ParseConditions(JArray conditions, AnimatorController ctrl)
|
||||
{
|
||||
var parsed = new List<ParsedCondition>();
|
||||
if (conditions == null)
|
||||
return parsed;
|
||||
|
||||
foreach (var token in conditions)
|
||||
{
|
||||
if (!(token is JObject obj))
|
||||
throw new ArgumentException("Each condition must be an object: { parameter, mode, threshold? }.");
|
||||
|
||||
var parameterName = obj["parameter"]?.ToObject<string>();
|
||||
var modeName = obj["mode"]?.ToObject<string>();
|
||||
if (string.IsNullOrWhiteSpace(parameterName))
|
||||
throw new ArgumentException("Each condition requires a 'parameter'.");
|
||||
if (string.IsNullOrWhiteSpace(modeName))
|
||||
throw new ArgumentException($"Condition for parameter '{parameterName}' requires a 'mode'.");
|
||||
|
||||
var param = ctrl.parameters.FirstOrDefault(p => p.name == parameterName);
|
||||
if (param == null)
|
||||
throw new ArgumentException($"Condition references parameter '{parameterName}', which does not exist on the controller.");
|
||||
|
||||
if (!Enum.TryParse<AnimatorConditionMode>(modeName, ignoreCase: true, out var mode))
|
||||
throw new ArgumentException($"Unknown condition mode '{modeName}' for parameter '{parameterName}'. Use If | IfNot | Greater | Less | Equals | NotEqual.");
|
||||
|
||||
ValidateModeForType(parameterName, param.type, mode);
|
||||
|
||||
var threshold = obj["threshold"]?.ToObject<float>() ?? 0f;
|
||||
|
||||
parsed.Add(new ParsedCondition { Parameter = parameterName, Mode = mode, Threshold = threshold });
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enforce that a condition mode matches its parameter type: Bool/Trigger only accept If/IfNot;
|
||||
/// Float/Int only accept the numeric modes (Greater/Less/Equals/NotEqual). Unity's editor follows
|
||||
/// the same rule; we reject mismatches up front with an actionable message.
|
||||
/// </summary>
|
||||
private static void ValidateModeForType(string parameterName, AnimatorControllerParameterType type, AnimatorConditionMode mode)
|
||||
{
|
||||
var isBoolLike = type == AnimatorControllerParameterType.Bool || type == AnimatorControllerParameterType.Trigger;
|
||||
var isNumeric = type == AnimatorControllerParameterType.Float || type == AnimatorControllerParameterType.Int;
|
||||
|
||||
var boolMode = mode == AnimatorConditionMode.If || mode == AnimatorConditionMode.IfNot;
|
||||
|
||||
if (isBoolLike && !boolMode)
|
||||
throw new ArgumentException(
|
||||
$"Condition mode '{mode}' is not valid for {type} parameter '{parameterName}'. Bool/Trigger parameters use If or IfNot.");
|
||||
|
||||
if (isNumeric && boolMode)
|
||||
throw new ArgumentException(
|
||||
$"Condition mode '{mode}' is not valid for {type} parameter '{parameterName}'. Float/Int parameters use Greater, Less, Equals or NotEqual.");
|
||||
}
|
||||
|
||||
private static AnimatorControllerParameterType ParseParameterType(string type)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(type))
|
||||
throw new ArgumentException("type is required.");
|
||||
if (!Enum.TryParse<AnimatorControllerParameterType>(type, ignoreCase: true, out var parsed))
|
||||
throw new ArgumentException($"Unknown parameter type '{type}'. Use Float | Int | Bool | Trigger.");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private static JToken DefaultValueToken(AnimatorControllerParameterType type, float f, int i, bool b)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AnimatorControllerParameterType.Float: return new JValue(f);
|
||||
case AnimatorControllerParameterType.Int: return new JValue(i);
|
||||
case AnimatorControllerParameterType.Bool: return new JValue(b);
|
||||
default: return JValue.CreateNull(); // Trigger has no default value
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a layer selector (int index or string name; null/absent => 0). Returns false with an
|
||||
/// error when a name doesn't match any layer (the caller surfaces 'layer_not_found').
|
||||
/// </summary>
|
||||
private static bool TryResolveLayer(AnimatorController ctrl, JToken layer, out int index, out string error)
|
||||
{
|
||||
index = 0;
|
||||
error = null;
|
||||
|
||||
if (layer == null || layer.Type == JTokenType.Null)
|
||||
return true; // default Base Layer
|
||||
|
||||
if (layer.Type == JTokenType.Integer || layer.Type == JTokenType.Float)
|
||||
{
|
||||
// The layer index is an int. A float is accepted only when it is integer-valued
|
||||
// (e.g. 1.0); a fractional value like 0.9 is rejected rather than silently rounded.
|
||||
if (layer.Type == JTokenType.Float)
|
||||
{
|
||||
var asDouble = layer.ToObject<double>();
|
||||
if (asDouble != Math.Floor(asDouble))
|
||||
{
|
||||
error = $"Layer index must be an integer; got {asDouble}.";
|
||||
return false;
|
||||
}
|
||||
index = (int)asDouble;
|
||||
}
|
||||
else
|
||||
{
|
||||
index = layer.ToObject<int>();
|
||||
}
|
||||
|
||||
if (index < 0 || index >= ctrl.layers.Length)
|
||||
{
|
||||
error = $"Layer index {index} is out of range (controller has {ctrl.layers.Length} layer(s)).";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
var name = layer.ToObject<string>();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return true;
|
||||
|
||||
// A numeric string is treated as an index.
|
||||
if (int.TryParse(name, out var asIndex))
|
||||
{
|
||||
if (asIndex < 0 || asIndex >= ctrl.layers.Length)
|
||||
{
|
||||
error = $"Layer index {asIndex} is out of range (controller has {ctrl.layers.Length} layer(s)).";
|
||||
return false;
|
||||
}
|
||||
index = asIndex;
|
||||
return true;
|
||||
}
|
||||
|
||||
for (int i = 0; i < ctrl.layers.Length; i++)
|
||||
{
|
||||
if (ctrl.layers[i].name == name)
|
||||
{
|
||||
index = i;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
error = $"No layer named '{name}' on the controller. Add it first with add_animator_layer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reject an on-disk asset reference that resolves outside the authoring root. Mirrors the
|
||||
/// confinement applied to the controller itself so a restricted root can't be sidestepped by
|
||||
/// pointing at (e.g.) an AnimationClip/BlendTree elsewhere in the project.
|
||||
/// </summary>
|
||||
private static void ConfineToAuthoringRoot(UnityEngine.Object asset, string label, ObjectRef reference)
|
||||
{
|
||||
var assetPath = AssetDatabase.GetAssetPath(asset);
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
throw new ArgumentException($"{label} reference '{reference}' does not point at an on-disk asset.");
|
||||
|
||||
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
|
||||
if (confined == null)
|
||||
throw new ArgumentException(
|
||||
$"{label} '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
|
||||
}
|
||||
|
||||
private static (AnimatorController controller, string path) ResolveController(ObjectRef controller)
|
||||
{
|
||||
if (controller == null || controller.IsEmpty)
|
||||
throw new ArgumentException("controller is required.");
|
||||
|
||||
if (!ObjectResolver.TryResolve(controller, out var obj, out var error))
|
||||
throw new ArgumentException(error);
|
||||
|
||||
if (!(obj is AnimatorController ctrl))
|
||||
throw new ArgumentException($"Reference '{controller}' resolved to a {obj.GetType().Name}, not an AnimatorController.");
|
||||
|
||||
var assetPath = AssetDatabase.GetAssetPath(ctrl);
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
throw new ArgumentException($"Reference '{controller}' does not point at an on-disk AnimatorController.");
|
||||
|
||||
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
|
||||
if (confined == null)
|
||||
throw new ArgumentException(
|
||||
$"Controller '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
|
||||
|
||||
return (ctrl, confined);
|
||||
}
|
||||
|
||||
private static void Persist(AnimatorController ctrl)
|
||||
{
|
||||
EditorUtility.SetDirty(ctrl);
|
||||
AssetDatabase.SaveAssets();
|
||||
}
|
||||
|
||||
private static void FillIdentity(AuthoringResult result, AnimatorController ctrl)
|
||||
{
|
||||
var described = ObjectResolver.Describe(ctrl);
|
||||
if (described == null)
|
||||
return;
|
||||
result.Guid = described.Guid;
|
||||
result.FileId = described.FileId;
|
||||
result.GlobalId = described.GlobalId;
|
||||
}
|
||||
|
||||
private static void EnsureParentFolder(string assetPath)
|
||||
{
|
||||
var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent))
|
||||
return;
|
||||
CreateFolderRecursive(parent);
|
||||
}
|
||||
|
||||
private static void CreateFolderRecursive(string assetsPath)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(assetsPath))
|
||||
return;
|
||||
|
||||
var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/');
|
||||
var name = Path.GetFileName(assetsPath);
|
||||
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
|
||||
throw new ArgumentException($"Invalid folder path '{assetsPath}'.");
|
||||
|
||||
if (!AssetDatabase.IsValidFolder(parent))
|
||||
CreateFolderRecursive(parent);
|
||||
|
||||
AssetDatabase.CreateFolder(parent, name);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- result models ----
|
||||
|
||||
/// <summary>A structured, non-throwing error envelope ({ error, code }) returned at HTTP 200.</summary>
|
||||
[Serializable]
|
||||
class ErrorResult
|
||||
{
|
||||
[JsonProperty("error")]
|
||||
public string Error { get; set; }
|
||||
|
||||
[JsonProperty("code")]
|
||||
public string Code { get; set; }
|
||||
|
||||
public static ErrorResult PackageStyle(string code, string error) => new ErrorResult { Code = code, Error = error };
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class ParameterInfo
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("type")]
|
||||
public string ParamType { get; set; }
|
||||
|
||||
[JsonProperty("defaultValue")]
|
||||
public JToken DefaultValue { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class AddParameterResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("parameter")]
|
||||
public ParameterInfo Parameter { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class LayerSummary
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("index")]
|
||||
public int Index { get; set; }
|
||||
|
||||
[JsonProperty("weight")]
|
||||
public float Weight { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class AddLayerResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("layer")]
|
||||
public LayerSummary Layer { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class StateSummary
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("layer")]
|
||||
public int Layer { get; set; }
|
||||
|
||||
[JsonProperty("hasMotion")]
|
||||
public bool HasMotion { get; set; }
|
||||
|
||||
[JsonProperty("isDefault")]
|
||||
public bool IsDefault { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class AddStateResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("state")]
|
||||
public StateSummary State { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class TransitionSummary
|
||||
{
|
||||
[JsonProperty("from")]
|
||||
public string From { get; set; }
|
||||
|
||||
[JsonProperty("to")]
|
||||
public string To { get; set; }
|
||||
|
||||
[JsonProperty("conditionCount")]
|
||||
public int ConditionCount { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class AddTransitionResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("transition")]
|
||||
public TransitionSummary Transition { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class AnimatorControllerInfo
|
||||
{
|
||||
[JsonProperty("assetPath")]
|
||||
public string AssetPath { get; set; }
|
||||
|
||||
[JsonProperty("parameters")]
|
||||
public List<ParameterInfo> Parameters { get; set; } = new List<ParameterInfo>();
|
||||
|
||||
[JsonProperty("layers")]
|
||||
public List<LayerInfo> Layers { get; set; } = new List<LayerInfo>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class LayerInfo
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("index")]
|
||||
public int Index { get; set; }
|
||||
|
||||
[JsonProperty("defaultState")]
|
||||
public string DefaultState { get; set; }
|
||||
|
||||
[JsonProperty("states")]
|
||||
public List<StateInfo> States { get; set; } = new List<StateInfo>();
|
||||
|
||||
[JsonProperty("transitions")]
|
||||
public List<TransitionInfo> Transitions { get; set; } = new List<TransitionInfo>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class StateInfo
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("motion")]
|
||||
public string Motion { get; set; }
|
||||
|
||||
[JsonProperty("isDefault")]
|
||||
public bool IsDefault { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class TransitionInfo
|
||||
{
|
||||
[JsonProperty("from")]
|
||||
public string From { get; set; }
|
||||
|
||||
[JsonProperty("to")]
|
||||
public string To { get; set; }
|
||||
|
||||
[JsonProperty("conditions")]
|
||||
public List<ConditionInfo> Conditions { get; set; } = new List<ConditionInfo>();
|
||||
|
||||
[JsonProperty("hasExitTime")]
|
||||
public bool HasExitTime { get; set; }
|
||||
|
||||
[JsonProperty("duration")]
|
||||
public float Duration { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class ConditionInfo
|
||||
{
|
||||
[JsonProperty("parameter")]
|
||||
public string Parameter { get; set; }
|
||||
|
||||
[JsonProperty("mode")]
|
||||
public string Mode { get; set; }
|
||||
|
||||
[JsonProperty("threshold")]
|
||||
public float Threshold { get; set; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 426c006ffb43461e94ab3dde2caed7be
|
||||
@@ -0,0 +1,641 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// Group C of CLI-214: author <c>TimelineAsset</c>s (tracks + clips). Timeline ships in the OPTIONAL
|
||||
/// <c>com.unity.timeline</c> package, so the Editor assembly does NOT reference <c>Unity.Timeline</c>
|
||||
/// (that would break compilation when the package is absent). Every command:
|
||||
///
|
||||
/// 1. runs <see cref="TimelineGuard.IsInstalled"/>; if Timeline is absent it returns a structured
|
||||
/// <c>{ error, code: "package_not_found" }</c> at HTTP 200 (no exception thrown), and
|
||||
/// 2. reaches the Timeline API entirely through reflection.
|
||||
///
|
||||
/// Type names used (all in <c>Unity.Timeline</c>): <c>UnityEngine.Timeline.TimelineAsset</c>,
|
||||
/// <c>TrackAsset</c>, <c>TimelineClip</c>, and the concrete track types
|
||||
/// (<c>AnimationTrack</c>, <c>AudioTrack</c>, <c>ActivationTrack</c>, <c>ControlTrack</c>,
|
||||
/// <c>PlayableTrack</c>, <c>SignalTrack</c>, <c>MarkerTrack</c>).
|
||||
///
|
||||
/// Out of scope (v1): markers, signal emitters/receivers, custom playable tracks, and track bindings
|
||||
/// to scene objects.
|
||||
/// </summary>
|
||||
static class TimelineCommands
|
||||
{
|
||||
private const string Asm = "Unity.Timeline";
|
||||
|
||||
// Friendly track-type name => concrete TrackAsset subclass full name.
|
||||
private static readonly Dictionary<string, string> TrackTypeNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "Animation", "UnityEngine.Timeline.AnimationTrack" },
|
||||
{ "Audio", "UnityEngine.Timeline.AudioTrack" },
|
||||
{ "Activation", "UnityEngine.Timeline.ActivationTrack" },
|
||||
{ "Control", "UnityEngine.Timeline.ControlTrack" },
|
||||
{ "Playable", "UnityEngine.Timeline.PlayableTrack" },
|
||||
{ "Signal", "UnityEngine.Timeline.SignalTrack" },
|
||||
{ "Marker", "UnityEngine.Timeline.MarkerTrack" },
|
||||
};
|
||||
|
||||
[CliCommand("create_timeline",
|
||||
"Create a .playable TimelineAsset under the authoring root (optional frame rate). Requires the com.unity.timeline package.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "animation/timeline" })]
|
||||
public static object CreateTimeline(
|
||||
[CliArg("path", "Asset path ending in .playable, relative to the authoring root. The Assets/ prefix is optional.", Required = true)] string path,
|
||||
[CliArg("frameRate", "Timeline frame rate (default 60).")] float frameRate = 60f,
|
||||
[CliArg("confirm", "Required (true) only when overwriting an existing asset at the path.")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, validate inputs and report what would be created without writing anything.")] bool dryRun = false)
|
||||
{
|
||||
if (!TimelineGuard.IsInstalled())
|
||||
return TimelineGuard.NotInstalledError();
|
||||
|
||||
var normalized = ProjectPaths.Resolve(path, out var error);
|
||||
if (normalized == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
if (!string.Equals(Path.GetExtension(normalized), ".playable", StringComparison.OrdinalIgnoreCase))
|
||||
throw new ArgumentException($"Timeline path '{normalized}' must end in .playable.");
|
||||
|
||||
if (frameRate <= 0f)
|
||||
throw new ArgumentException($"frameRate must be positive (got {frameRate}).");
|
||||
|
||||
var exists = AssetDatabase.LoadMainAssetAtPath(normalized) != null;
|
||||
if (exists && !confirm)
|
||||
throw new ArgumentException($"An asset already exists at '{normalized}'. Pass confirm=true to overwrite it.");
|
||||
|
||||
if (dryRun)
|
||||
return new AuthoringResult { AssetPath = normalized, Type = "TimelineAsset" };
|
||||
|
||||
EnsureParentFolder(normalized);
|
||||
|
||||
var timelineType = TimelineGuard.ResolveTimelineAssetType();
|
||||
var timeline = ScriptableObject.CreateInstance(timelineType);
|
||||
SetFrameRate(timeline, timelineType, frameRate);
|
||||
|
||||
if (exists)
|
||||
AssetDatabase.DeleteAsset(normalized);
|
||||
|
||||
AssetDatabase.CreateAsset(timeline, normalized);
|
||||
EditorUtility.SetDirty(timeline);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.ImportAsset(normalized);
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = "TimelineAsset" };
|
||||
result.AssetPath = normalized;
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("add_timeline_track",
|
||||
"Add a track (Animation | Audio | Activation | Control | Playable | Signal | Marker) to a TimelineAsset, optionally nested under a parent group/track. Requires the com.unity.timeline package.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "animation/timeline" })]
|
||||
public static object AddTimelineTrack(
|
||||
[CliArg("timeline", "Reference to the TimelineAsset to edit (path / guid / globalId).", Required = true)] ObjectRef timeline,
|
||||
[CliArg("trackType", "Track type: Animation | Audio | Activation | Control | Playable | Signal | Marker.", Required = true)] string trackType = null,
|
||||
[CliArg("name", "Optional track display name.")] string name = null,
|
||||
[CliArg("parentTrack", "Optional name of an existing group/track to nest the new track under.")] string parentTrack = null,
|
||||
[CliArg("dry_run", "If true, validate inputs without writing the track.")] bool dryRun = false)
|
||||
{
|
||||
if (!TimelineGuard.IsInstalled())
|
||||
return TimelineGuard.NotInstalledError();
|
||||
|
||||
var (asset, assetPath, timelineType) = ResolveTimeline(timeline);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trackType) || !TrackTypeNames.TryGetValue(trackType, out var trackTypeFullName))
|
||||
throw new ArgumentException($"Unknown trackType '{trackType}'. Use Animation | Audio | Activation | Control | Playable | Signal | Marker.");
|
||||
|
||||
var concreteTrackType = Type.GetType($"{trackTypeFullName}, {Asm}", throwOnError: false);
|
||||
if (concreteTrackType == null)
|
||||
throw new ArgumentException($"Track type '{trackTypeFullName}' is not available in this version of {TimelineGuard.PackageId}.");
|
||||
|
||||
var trackName = string.IsNullOrEmpty(name) ? trackType : name;
|
||||
|
||||
// Resolve an optional parent track by name BEFORE any write so a bad parent fails clean.
|
||||
object parent = null;
|
||||
if (!string.IsNullOrEmpty(parentTrack))
|
||||
{
|
||||
parent = FindTrackByName(asset, timelineType, parentTrack);
|
||||
if (parent == null)
|
||||
throw new ArgumentException($"Parent track '{parentTrack}' not found on the timeline.");
|
||||
}
|
||||
|
||||
var result = new AddTimelineTrackResult
|
||||
{
|
||||
AssetPath = assetPath,
|
||||
Type = "TimelineAsset",
|
||||
Track = new TimelineTrackSummary { Name = trackName, TrackType = trackType }
|
||||
};
|
||||
|
||||
if (dryRun)
|
||||
return result;
|
||||
|
||||
// TrackAsset CreateTrack(Type type, TrackAsset parent, string name)
|
||||
var createTrack = timelineType.GetMethod("CreateTrack", new[] { typeof(Type), GetTrackAssetType(), typeof(string) });
|
||||
if (createTrack == null)
|
||||
throw new InvalidOperationException("TimelineAsset.CreateTrack(Type, TrackAsset, string) not found via reflection.");
|
||||
|
||||
createTrack.Invoke(asset, new object[] { concreteTrackType, parent, trackName });
|
||||
|
||||
EditorUtility.SetDirty(asset);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
FillIdentity(result, asset);
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("add_timeline_clip",
|
||||
"Add a clip to a named track on a TimelineAsset. For Animation tracks pass an AnimationClip asset; for Audio tracks an AudioClip. Requires the com.unity.timeline package.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "animation/timeline" })]
|
||||
public static object AddTimelineClip(
|
||||
[CliArg("timeline", "Reference to the TimelineAsset to edit (path / guid / globalId).", Required = true)] ObjectRef timeline,
|
||||
[CliArg("track", "Target track name.", Required = true)] string track = null,
|
||||
[CliArg("start", "Clip start time in seconds.", Required = true)] float start = 0f,
|
||||
[CliArg("duration", "Clip duration in seconds.", Required = true)] float duration = 0f,
|
||||
[CliArg("asset", "Source asset: for Animation tracks an AnimationClip; for Audio tracks an AudioClip. Required for those track types.")] ObjectRef asset = null,
|
||||
[CliArg("dry_run", "If true, validate inputs without writing the clip.")] bool dryRun = false)
|
||||
{
|
||||
if (!TimelineGuard.IsInstalled())
|
||||
return TimelineGuard.NotInstalledError();
|
||||
|
||||
var (timelineAsset, assetPath, timelineType) = ResolveTimeline(timeline);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(track))
|
||||
throw new ArgumentException("track is required.");
|
||||
if (duration <= 0f)
|
||||
throw new ArgumentException($"duration must be positive (got {duration}).");
|
||||
if (start < 0f)
|
||||
throw new ArgumentException($"start must be >= 0 (got {start}).");
|
||||
|
||||
var trackObj = FindTrackByName(timelineAsset, timelineType, track);
|
||||
if (trackObj == null)
|
||||
throw new ArgumentException($"Track '{track}' not found on the timeline.");
|
||||
|
||||
// Resolve the optional clip-source asset BEFORE any write.
|
||||
Object sourceAsset = null;
|
||||
if (asset != null && !asset.IsEmpty)
|
||||
{
|
||||
if (!ObjectResolver.TryResolve(asset, out var srcObj, out var srcError))
|
||||
throw new ArgumentException($"Could not resolve clip asset: {srcError}");
|
||||
sourceAsset = srcObj;
|
||||
|
||||
// Re-confine the clip source to the authoring root: a restricted root must not be
|
||||
// bypassed by referencing an AnimationClip/AudioClip that lives outside the sandbox.
|
||||
var sourcePath = AssetDatabase.GetAssetPath(sourceAsset);
|
||||
if (string.IsNullOrEmpty(sourcePath))
|
||||
throw new ArgumentException($"Clip asset reference '{asset}' does not point at an on-disk asset.");
|
||||
|
||||
var confinedSource = ProjectPaths.Resolve(sourcePath, out var sourceConfineError);
|
||||
if (confinedSource == null)
|
||||
throw new ArgumentException(
|
||||
$"Clip asset '{sourcePath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {sourceConfineError}");
|
||||
}
|
||||
|
||||
var result = new AddTimelineClipResult
|
||||
{
|
||||
AssetPath = assetPath,
|
||||
Type = "TimelineAsset",
|
||||
Clip = new TimelineClipSummary { Track = track, Start = start, Duration = duration }
|
||||
};
|
||||
|
||||
if (dryRun)
|
||||
{
|
||||
// Even in dry_run, validate that a clip can be created for this track/asset combination so
|
||||
// a missing-required-asset case fails fast and writes nothing.
|
||||
ValidateClipCreatable(trackObj, sourceAsset);
|
||||
return result;
|
||||
}
|
||||
|
||||
var clip = CreateClip(trackObj, sourceAsset);
|
||||
if (clip == null)
|
||||
throw new InvalidOperationException($"Failed to create a clip on track '{track}'.");
|
||||
|
||||
SetClipTiming(clip, start, duration);
|
||||
|
||||
EditorUtility.SetDirty(timelineAsset);
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
FillIdentity(result, timelineAsset);
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("get_timeline",
|
||||
"Read a TimelineAsset's structure: frame rate, duration, and its tracks with their clips. Requires the com.unity.timeline package.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "animation/timeline" })]
|
||||
public static object GetTimeline(
|
||||
[CliArg("timeline", "Reference to the TimelineAsset to read (path / guid / globalId).", Required = true)] ObjectRef timeline)
|
||||
{
|
||||
if (!TimelineGuard.IsInstalled())
|
||||
return TimelineGuard.NotInstalledError();
|
||||
|
||||
var (asset, assetPath, timelineType) = ResolveTimeline(timeline);
|
||||
|
||||
var info = new TimelineInfo
|
||||
{
|
||||
AssetPath = assetPath,
|
||||
FrameRate = GetFrameRate(asset, timelineType),
|
||||
Duration = GetDouble(asset, timelineType, "duration")
|
||||
};
|
||||
|
||||
foreach (var trackObj in GetOutputTracks(asset, timelineType))
|
||||
{
|
||||
if (trackObj == null)
|
||||
continue;
|
||||
|
||||
var trackType = trackObj.GetType();
|
||||
var trackInfo = new TimelineTrackInfo
|
||||
{
|
||||
Name = GetName(trackObj),
|
||||
TrackType = FriendlyTrackType(trackType)
|
||||
};
|
||||
|
||||
foreach (var clipObj in GetClips(trackObj))
|
||||
{
|
||||
if (clipObj == null)
|
||||
continue;
|
||||
var clipType = clipObj.GetType();
|
||||
var clipAsset = GetMember(clipObj, clipType, "asset") as Object;
|
||||
trackInfo.Clips.Add(new TimelineClipInfo
|
||||
{
|
||||
Start = GetDoubleMember(clipObj, clipType, "start"),
|
||||
Duration = GetDoubleMember(clipObj, clipType, "duration"),
|
||||
Asset = clipAsset != null ? AssetDatabase.GetAssetPath(clipAsset) : null
|
||||
});
|
||||
}
|
||||
|
||||
info.Tracks.Add(trackInfo);
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
// ---- reflection helpers ----
|
||||
|
||||
private static Type GetTrackAssetType()
|
||||
{
|
||||
var t = Type.GetType($"UnityEngine.Timeline.TrackAsset, {Asm}", throwOnError: false);
|
||||
if (t == null)
|
||||
throw new InvalidOperationException("UnityEngine.Timeline.TrackAsset type not found.");
|
||||
return t;
|
||||
}
|
||||
|
||||
private static (Object asset, string path, Type timelineType) ResolveTimeline(ObjectRef timeline)
|
||||
{
|
||||
if (timeline == null || timeline.IsEmpty)
|
||||
throw new ArgumentException("timeline is required.");
|
||||
|
||||
if (!ObjectResolver.TryResolve(timeline, out var obj, out var error))
|
||||
throw new ArgumentException(error);
|
||||
|
||||
var timelineType = TimelineGuard.ResolveTimelineAssetType();
|
||||
if (timelineType == null || !timelineType.IsInstanceOfType(obj))
|
||||
throw new ArgumentException($"Reference '{timeline}' resolved to a {obj.GetType().Name}, not a TimelineAsset.");
|
||||
|
||||
var assetPath = AssetDatabase.GetAssetPath(obj);
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
throw new ArgumentException($"Reference '{timeline}' does not point at an on-disk TimelineAsset.");
|
||||
|
||||
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
|
||||
if (confined == null)
|
||||
throw new ArgumentException(
|
||||
$"Timeline '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
|
||||
|
||||
return (obj, confined, timelineType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the timeline frame rate, preferring the current <c>editorSettings.frameRate</c> (double)
|
||||
/// and falling back to the obsolete <c>editorSettings.fps</c> (float) on older package versions.
|
||||
/// </summary>
|
||||
private static void SetFrameRate(object timeline, Type timelineType, float frameRate)
|
||||
{
|
||||
var editorSettings = GetMember(timeline, timelineType, "editorSettings");
|
||||
if (editorSettings == null)
|
||||
return;
|
||||
|
||||
var settingsType = editorSettings.GetType();
|
||||
|
||||
var frameRateProp = settingsType.GetProperty("frameRate", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (frameRateProp != null && frameRateProp.CanWrite)
|
||||
{
|
||||
frameRateProp.SetValue(editorSettings, (double)frameRate);
|
||||
return;
|
||||
}
|
||||
|
||||
var fpsProp = settingsType.GetProperty("fps", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (fpsProp != null && fpsProp.CanWrite)
|
||||
fpsProp.SetValue(editorSettings, frameRate);
|
||||
}
|
||||
|
||||
private static double GetFrameRate(object timeline, Type timelineType)
|
||||
{
|
||||
var editorSettings = GetMember(timeline, timelineType, "editorSettings");
|
||||
if (editorSettings == null)
|
||||
return 0d;
|
||||
|
||||
var settingsType = editorSettings.GetType();
|
||||
|
||||
var frameRateProp = settingsType.GetProperty("frameRate", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (frameRateProp != null && frameRateProp.CanRead)
|
||||
return Convert.ToDouble(frameRateProp.GetValue(editorSettings));
|
||||
|
||||
var fpsProp = settingsType.GetProperty("fps", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (fpsProp != null && fpsProp.CanRead)
|
||||
return Convert.ToDouble(fpsProp.GetValue(editorSettings));
|
||||
|
||||
return 0d;
|
||||
}
|
||||
|
||||
private static IEnumerable<object> GetOutputTracks(object timeline, Type timelineType)
|
||||
{
|
||||
var method = timelineType.GetMethod("GetOutputTracks", Type.EmptyTypes);
|
||||
if (method == null)
|
||||
yield break;
|
||||
|
||||
var enumerable = method.Invoke(timeline, null) as IEnumerable;
|
||||
if (enumerable == null)
|
||||
yield break;
|
||||
|
||||
foreach (var item in enumerable)
|
||||
yield return item;
|
||||
}
|
||||
|
||||
private static object FindTrackByName(object timeline, Type timelineType, string name)
|
||||
{
|
||||
foreach (var track in GetOutputTracks(timeline, timelineType))
|
||||
{
|
||||
if (track != null && GetName(track) == name)
|
||||
return track;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<object> GetClips(object track)
|
||||
{
|
||||
var method = track.GetType().GetMethod("GetClips", Type.EmptyTypes);
|
||||
if (method == null)
|
||||
yield break;
|
||||
|
||||
var enumerable = method.Invoke(track, null) as IEnumerable;
|
||||
if (enumerable == null)
|
||||
yield break;
|
||||
|
||||
foreach (var item in enumerable)
|
||||
yield return item;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find a <c>CreateClip</c> overload on the track that accepts the given source asset's type
|
||||
/// (e.g. AnimationTrack.CreateClip(AnimationClip), AudioTrack.CreateClip(AudioClip)). Returns
|
||||
/// null when no asset is given (caller then uses CreateDefaultClip).
|
||||
/// </summary>
|
||||
private static MethodInfo FindCreateClipForAsset(Type trackType, Object sourceAsset)
|
||||
{
|
||||
if (sourceAsset == null)
|
||||
return null;
|
||||
|
||||
var assetType = sourceAsset.GetType();
|
||||
MethodInfo best = null;
|
||||
foreach (var m in trackType.GetMethods(BindingFlags.Public | BindingFlags.Instance))
|
||||
{
|
||||
if (m.Name != "CreateClip")
|
||||
continue;
|
||||
if (m.IsGenericMethodDefinition)
|
||||
continue;
|
||||
var ps = m.GetParameters();
|
||||
if (ps.Length != 1)
|
||||
continue;
|
||||
if (ps[0].ParameterType.IsAssignableFrom(assetType))
|
||||
{
|
||||
// Prefer an exact match over a UnityEngine.Object-typed overload.
|
||||
if (ps[0].ParameterType == assetType)
|
||||
return m;
|
||||
best = best ?? m;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static void ValidateClipCreatable(object track, Object sourceAsset)
|
||||
{
|
||||
var trackType = track.GetType();
|
||||
var isAnimation = trackType.FullName == "UnityEngine.Timeline.AnimationTrack";
|
||||
var isAudio = trackType.FullName == "UnityEngine.Timeline.AudioTrack";
|
||||
|
||||
if ((isAnimation || isAudio) && sourceAsset == null)
|
||||
throw new ArgumentException($"Track type '{FriendlyTrackType(trackType)}' requires an 'asset' (AnimationClip / AudioClip) to create a clip.");
|
||||
|
||||
if (sourceAsset != null && FindCreateClipForAsset(trackType, sourceAsset) == null)
|
||||
throw new ArgumentException(
|
||||
$"Track type '{FriendlyTrackType(trackType)}' has no CreateClip overload accepting a {sourceAsset.GetType().Name}.");
|
||||
}
|
||||
|
||||
private static object CreateClip(object track, Object sourceAsset)
|
||||
{
|
||||
var trackType = track.GetType();
|
||||
|
||||
if (sourceAsset != null)
|
||||
{
|
||||
var createClip = FindCreateClipForAsset(trackType, sourceAsset);
|
||||
if (createClip == null)
|
||||
throw new ArgumentException(
|
||||
$"Track type '{FriendlyTrackType(trackType)}' has no CreateClip overload accepting a {sourceAsset.GetType().Name}.");
|
||||
return createClip.Invoke(track, new object[] { sourceAsset });
|
||||
}
|
||||
|
||||
var isAnimation = trackType.FullName == "UnityEngine.Timeline.AnimationTrack";
|
||||
var isAudio = trackType.FullName == "UnityEngine.Timeline.AudioTrack";
|
||||
if (isAnimation || isAudio)
|
||||
throw new ArgumentException($"Track type '{FriendlyTrackType(trackType)}' requires an 'asset' (AnimationClip / AudioClip) to create a clip.");
|
||||
|
||||
// For tracks that don't take a source asset, create a default (empty) clip.
|
||||
var createDefault = trackType.GetMethod("CreateDefaultClip", Type.EmptyTypes);
|
||||
if (createDefault != null)
|
||||
return createDefault.Invoke(track, null);
|
||||
|
||||
throw new InvalidOperationException($"No CreateClip / CreateDefaultClip available on '{trackType.Name}'.");
|
||||
}
|
||||
|
||||
private static void SetClipTiming(object clip, double start, double duration)
|
||||
{
|
||||
var clipType = clip.GetType();
|
||||
SetDoubleMember(clip, clipType, "start", start);
|
||||
SetDoubleMember(clip, clipType, "duration", duration);
|
||||
}
|
||||
|
||||
private static string FriendlyTrackType(Type trackType)
|
||||
{
|
||||
foreach (var pair in TrackTypeNames)
|
||||
{
|
||||
if (pair.Value == trackType.FullName)
|
||||
return pair.Key;
|
||||
}
|
||||
// Strip the trailing "Track" suffix for any unmapped subclass.
|
||||
var n = trackType.Name;
|
||||
return n.EndsWith("Track", StringComparison.Ordinal) ? n.Substring(0, n.Length - "Track".Length) : n;
|
||||
}
|
||||
|
||||
private static string GetName(object obj)
|
||||
{
|
||||
var prop = obj.GetType().GetProperty("name", BindingFlags.Public | BindingFlags.Instance);
|
||||
return prop?.GetValue(obj) as string;
|
||||
}
|
||||
|
||||
private static object GetMember(object obj, Type type, string name)
|
||||
{
|
||||
var prop = type.GetProperty(name, BindingFlags.Public | BindingFlags.Instance);
|
||||
if (prop != null)
|
||||
return prop.GetValue(obj);
|
||||
var field = type.GetField(name, BindingFlags.Public | BindingFlags.Instance);
|
||||
return field?.GetValue(obj);
|
||||
}
|
||||
|
||||
private static double GetDouble(object obj, Type type, string name)
|
||||
{
|
||||
var value = GetMember(obj, type, name);
|
||||
return value == null ? 0d : Convert.ToDouble(value);
|
||||
}
|
||||
|
||||
private static double GetDoubleMember(object obj, Type type, string name) => GetDouble(obj, type, name);
|
||||
|
||||
private static void SetDoubleMember(object obj, Type type, string name, double value)
|
||||
{
|
||||
var prop = type.GetProperty(name, BindingFlags.Public | BindingFlags.Instance);
|
||||
if (prop != null && prop.CanWrite)
|
||||
{
|
||||
prop.SetValue(obj, value);
|
||||
return;
|
||||
}
|
||||
var field = type.GetField(name, BindingFlags.Public | BindingFlags.Instance);
|
||||
field?.SetValue(obj, value);
|
||||
}
|
||||
|
||||
private static void FillIdentity(AuthoringResult result, Object asset)
|
||||
{
|
||||
var described = ObjectResolver.Describe(asset);
|
||||
if (described == null)
|
||||
return;
|
||||
result.Guid = described.Guid;
|
||||
result.FileId = described.FileId;
|
||||
result.GlobalId = described.GlobalId;
|
||||
}
|
||||
|
||||
private static void EnsureParentFolder(string assetPath)
|
||||
{
|
||||
var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent))
|
||||
return;
|
||||
CreateFolderRecursive(parent);
|
||||
}
|
||||
|
||||
private static void CreateFolderRecursive(string assetsPath)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(assetsPath))
|
||||
return;
|
||||
|
||||
var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/');
|
||||
var name = Path.GetFileName(assetsPath);
|
||||
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
|
||||
throw new ArgumentException($"Invalid folder path '{assetsPath}'.");
|
||||
|
||||
if (!AssetDatabase.IsValidFolder(parent))
|
||||
CreateFolderRecursive(parent);
|
||||
|
||||
AssetDatabase.CreateFolder(parent, name);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- result models ----
|
||||
|
||||
[Serializable]
|
||||
class TimelineTrackSummary
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("type")]
|
||||
public string TrackType { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class AddTimelineTrackResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("track")]
|
||||
public TimelineTrackSummary Track { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class TimelineClipSummary
|
||||
{
|
||||
[JsonProperty("track")]
|
||||
public string Track { get; set; }
|
||||
|
||||
[JsonProperty("start")]
|
||||
public float Start { get; set; }
|
||||
|
||||
[JsonProperty("duration")]
|
||||
public float Duration { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class AddTimelineClipResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("clip")]
|
||||
public TimelineClipSummary Clip { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class TimelineInfo
|
||||
{
|
||||
[JsonProperty("assetPath")]
|
||||
public string AssetPath { get; set; }
|
||||
|
||||
[JsonProperty("frameRate")]
|
||||
public double FrameRate { get; set; }
|
||||
|
||||
[JsonProperty("duration")]
|
||||
public double Duration { get; set; }
|
||||
|
||||
[JsonProperty("tracks")]
|
||||
public List<TimelineTrackInfo> Tracks { get; set; } = new List<TimelineTrackInfo>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class TimelineTrackInfo
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("type")]
|
||||
public string TrackType { get; set; }
|
||||
|
||||
[JsonProperty("clips")]
|
||||
public List<TimelineClipInfo> Clips { get; set; } = new List<TimelineClipInfo>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
class TimelineClipInfo
|
||||
{
|
||||
[JsonProperty("start")]
|
||||
public double Start { get; set; }
|
||||
|
||||
[JsonProperty("duration")]
|
||||
public double Duration { get; set; }
|
||||
|
||||
[JsonProperty("asset")]
|
||||
public string Asset { get; set; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dcf5399fb1544ee3a5b069c535bcd002
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
using Unity.Scripting.LifecycleManagement;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// Presence guard for the optional <c>com.unity.timeline</c> package (CLI-214, Group C).
|
||||
///
|
||||
/// The Editor assembly deliberately does NOT reference <c>Unity.Timeline</c> (an asmdef reference
|
||||
/// would break compilation in any project that doesn't have the package installed). Instead, the
|
||||
/// Timeline commands reach the package via reflection, and this guard reports whether the package's
|
||||
/// core type is loadable — the optional-package guard pattern for commands backed by a package that
|
||||
/// may not be present.
|
||||
///
|
||||
/// The guard caches its result for the lifetime of the domain; a package add/remove triggers a
|
||||
/// domain reload, which resets the cache.
|
||||
/// </summary>
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
[NoAutoStaticsCleanup]
|
||||
#endif
|
||||
static class TimelineGuard
|
||||
{
|
||||
/// <summary>The package id, surfaced in the not-installed error message.</summary>
|
||||
public const string PackageId = "com.unity.timeline";
|
||||
|
||||
/// <summary>Assembly-qualified name of the package's root asset type.</summary>
|
||||
public const string TimelineAssetTypeName = "UnityEngine.Timeline.TimelineAsset, Unity.Timeline";
|
||||
|
||||
private static bool? s_Installed;
|
||||
|
||||
/// <summary>
|
||||
/// True when the Timeline package is installed (its <c>TimelineAsset</c> type resolves).
|
||||
/// </summary>
|
||||
public static bool IsInstalled()
|
||||
{
|
||||
if (s_Installed.HasValue)
|
||||
return s_Installed.Value;
|
||||
|
||||
s_Installed = ResolveTimelineAssetType() != null;
|
||||
return s_Installed.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>UnityEngine.Timeline.TimelineAsset</c> <see cref="Type"/>, or null if the package is
|
||||
/// absent. Callers use this as the entry point for further reflection.
|
||||
/// </summary>
|
||||
public static Type ResolveTimelineAssetType() => Type.GetType(TimelineAssetTypeName, throwOnError: false);
|
||||
|
||||
/// <summary>
|
||||
/// The structured "package not installed" error (HTTP 200, no exception) that every Timeline
|
||||
/// command returns when <see cref="IsInstalled"/> is false.
|
||||
/// </summary>
|
||||
public static ErrorResult NotInstalledError() =>
|
||||
ErrorResult.PackageStyle("package_not_found", $"{PackageId} is not installed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f3f8120d06814b2c86d46503ac132022
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 39163f849a3aebd47a3f718fdc0224f9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,585 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
#if !UNITY_6000_0_OR_NEWER
|
||||
using PhysicsMaterial = UnityEngine.PhysicMaterial;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Assets
|
||||
{
|
||||
/// <summary>
|
||||
/// Asset lifecycle authoring commands (CLI-191): create / import / move / copy / rename / delete
|
||||
/// project assets. They sit on top of the CLI-190 authoring foundation, so:
|
||||
///
|
||||
/// - every agent-supplied path is funnelled through <see cref="ProjectPaths.Resolve"/> (sandboxed
|
||||
/// to the authoring root; rejects "../" and out-of-project writes),
|
||||
/// - results are returned as the canonical <see cref="AuthoringResult"/> envelope so an agent can
|
||||
/// reference the asset in a follow-up call,
|
||||
/// - existing assets are addressed with an <see cref="ObjectRef"/> resolved by
|
||||
/// <see cref="ObjectResolver"/>.
|
||||
///
|
||||
/// NOTE: AssetDatabase create/move/copy/rename/delete are NOT part of Unity's Undo system, so
|
||||
/// <see cref="AuthoringUndoScope"/> would have no effect here — these operations are intentionally
|
||||
/// not wrapped in an undo scope, and destructive/overwriting operations instead require an explicit
|
||||
/// <c>confirm</c> argument (and support <c>dry_run</c>) so an agent cannot silently lose data.
|
||||
/// </summary>
|
||||
static class AssetCommands
|
||||
{
|
||||
[CliCommand("create_asset", "Create a new ScriptableObject (or other UnityEngine.Object) asset of the given type at a path under the authoring root.", Tags = new[] { "assets" })]
|
||||
public static AuthoringResult CreateAsset(
|
||||
[CliArg("path", "Asset path relative to the authoring root, including extension (e.g. Data/Config.asset or Materials/Wall.mat). The Assets/ prefix is optional.", Required = true)] string path,
|
||||
[CliArg("type", "Fully-qualified or short type name to instantiate (e.g. UnityEngine.Material, MyGame.GameConfig). Must derive from UnityEngine.Object and be creatable.", Required = true)] string type,
|
||||
[CliArg("shader", "Material-only (ignored otherwise): shader name to assign (e.g. Standard, \"Universal Render Pipeline/Lit\"). When omitted, defaults to \"Universal Render Pipeline/Lit\" if a Scriptable Render Pipeline is active, otherwise the built-in \"Standard\" shader (falling back to \"Standard\" if URP/Lit is unavailable).")] string shader = null,
|
||||
[CliArg("confirm", "Required (true) only when overwriting an existing asset at the path. Ignored when the path is empty.")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, validate inputs and report what would be created without writing anything.")] bool dryRun = false)
|
||||
{
|
||||
var normalized = ProjectPaths.Resolve(path, out var error);
|
||||
if (normalized == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
if (string.IsNullOrEmpty(Path.GetExtension(normalized)))
|
||||
throw new ArgumentException($"Asset path '{normalized}' must include a file extension (e.g. .asset, .mat).");
|
||||
|
||||
var resolvedType = ResolveType(type);
|
||||
if (resolvedType == null)
|
||||
throw new ArgumentException($"Could not resolve type '{type}'. Use a fully-qualified name (e.g. UnityEngine.Material).");
|
||||
if (!typeof(Object).IsAssignableFrom(resolvedType))
|
||||
throw new ArgumentException($"Type '{resolvedType.FullName}' does not derive from UnityEngine.Object.");
|
||||
|
||||
// CLI-222: Unity 6 renamed PhysicMaterial -> PhysicsMaterial, but the on-disk asset
|
||||
// extension is still ".physicMaterial". AssetDatabase.CreateAsset rejects a
|
||||
// ".physicsMaterial" file ("should not be used to create a file of type 'physicsMaterial'"),
|
||||
// so accept either spelling from the agent and normalize to the extension Unity writes.
|
||||
if (typeof(PhysicsMaterial).IsAssignableFrom(resolvedType) &&
|
||||
normalized.EndsWith(".physicsMaterial", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
normalized = normalized.Substring(0, normalized.Length - ".physicsMaterial".Length) + ".physicMaterial";
|
||||
}
|
||||
|
||||
var exists = AssetDatabase.LoadMainAssetAtPath(normalized) != null;
|
||||
if (exists && !confirm)
|
||||
throw new ArgumentException($"An asset already exists at '{normalized}'. Pass confirm=true to overwrite it.");
|
||||
|
||||
// CLI-221: for a Material, resolve (and thereby validate) the shader up front so dry_run
|
||||
// also fails fast on an unknown shader — dry_run is documented as "validate inputs".
|
||||
Shader materialShader = null;
|
||||
if (typeof(Material).IsAssignableFrom(resolvedType))
|
||||
materialShader = ResolveShader(shader);
|
||||
|
||||
if (dryRun)
|
||||
return new AuthoringResult { AssetPath = normalized, Type = resolvedType.Name };
|
||||
|
||||
EnsureParentFolder(normalized);
|
||||
|
||||
Object asset;
|
||||
if (typeof(ScriptableObject).IsAssignableFrom(resolvedType))
|
||||
{
|
||||
asset = ScriptableObject.CreateInstance(resolvedType);
|
||||
}
|
||||
else if (typeof(Material).IsAssignableFrom(resolvedType))
|
||||
{
|
||||
// CLI-221: Material has no public parameterless ctor (Activator.CreateInstance produces
|
||||
// an invalid Material with a null shader), so construct it explicitly from the shader
|
||||
// resolved (and validated) above. The `shader` arg is Material-specific and ignored for
|
||||
// every other type.
|
||||
asset = new Material(materialShader);
|
||||
}
|
||||
else if (typeof(PhysicsMaterial).IsAssignableFrom(resolvedType))
|
||||
{
|
||||
// CLI-222: PhysicsMaterial has a public parameterless ctor, but create it explicitly so
|
||||
// we can stamp the documented sensible defaults rather than relying on engine defaults.
|
||||
asset = new PhysicsMaterial
|
||||
{
|
||||
dynamicFriction = 0.6f,
|
||||
staticFriction = 0.6f,
|
||||
bounciness = 0f,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Other UnityEngine.Object subclasses with a public parameterless ctor.
|
||||
// Activator.CreateInstance throws for abstract types or types without an accessible
|
||||
// parameterless ctor — surface that as an actionable ArgumentException.
|
||||
try
|
||||
{
|
||||
asset = Activator.CreateInstance(resolvedType) as Object;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"Type '{resolvedType.FullName}' could not be instantiated (it may be abstract or lack a public parameterless constructor): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (asset == null)
|
||||
throw new ArgumentException($"Type '{resolvedType.FullName}' could not be instantiated as a creatable asset.");
|
||||
|
||||
// AssetDatabase.CreateAsset does not reliably overwrite an existing asset at the path, so
|
||||
// explicitly delete it first. The confirm guard above gates that an asset already exists.
|
||||
if (exists)
|
||||
AssetDatabase.DeleteAsset(normalized);
|
||||
|
||||
AssetDatabase.CreateAsset(asset, normalized);
|
||||
AssetDatabase.SaveAssets();
|
||||
AssetDatabase.ImportAsset(normalized);
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = resolvedType.Name };
|
||||
result.AssetPath = normalized;
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("import_asset", "Import an external file (e.g. a texture, model, audio clip) into the project by copying it to a path under the authoring root, then importing it.", Tags = new[] { "assets/import" })]
|
||||
public static AuthoringResult ImportAsset(
|
||||
[CliArg("source", "Absolute filesystem path to the external file to import.", Required = true)] string source,
|
||||
[CliArg("path", "Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional.", Required = true)] string path,
|
||||
[CliArg("confirm", "Required (true) only when overwriting an existing asset at the destination path.")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, validate inputs and report what would be imported without writing anything.")] bool dryRun = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(source))
|
||||
throw new ArgumentException("source is required.");
|
||||
if (!File.Exists(source))
|
||||
throw new ArgumentException($"Source file '{source}' does not exist.");
|
||||
|
||||
var normalized = ProjectPaths.Resolve(path, out var error);
|
||||
if (normalized == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
var exists = AssetDatabase.LoadMainAssetAtPath(normalized) != null || File.Exists(ToAbsolute(normalized));
|
||||
if (exists && !confirm)
|
||||
throw new ArgumentException($"An asset already exists at '{normalized}'. Pass confirm=true to overwrite it.");
|
||||
|
||||
if (dryRun)
|
||||
return new AuthoringResult { AssetPath = normalized, Type = "ImportedAsset" };
|
||||
|
||||
EnsureParentFolder(normalized);
|
||||
File.Copy(source, ToAbsolute(normalized), overwrite: true);
|
||||
AssetDatabase.ImportAsset(normalized, ImportAssetOptions.ForceUpdate);
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult();
|
||||
result.AssetPath = normalized;
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("move_asset", "Move (or rename via a new path) an asset to a new location under the authoring root. Preserves the asset's GUID.", Tags = new[] { "assets" })]
|
||||
public static AuthoringResult MoveAsset(
|
||||
[CliArg("asset", "Reference to the asset to move (path / guid / globalId).", Required = true)] ObjectRef asset,
|
||||
[CliArg("destination", "Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional.", Required = true)] string destination,
|
||||
[CliArg("dry_run", "If true, validate the move (via AssetDatabase.ValidateMoveAsset) without performing it.")] bool dryRun = false)
|
||||
{
|
||||
var sourcePath = ResolveAssetPath(asset);
|
||||
|
||||
var dest = ProjectPaths.Resolve(destination, out var error);
|
||||
if (dest == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
// For a real move, create the destination's parent folder *before* validating: Unity's
|
||||
// ValidateMoveAsset reports failure ("move as a subdirectory") when the target folder
|
||||
// doesn't exist yet. Dry-run must not create folders, so it validates against the current
|
||||
// project state only.
|
||||
if (!dryRun)
|
||||
EnsureParentFolder(dest);
|
||||
|
||||
var validation = AssetDatabase.ValidateMoveAsset(sourcePath, dest);
|
||||
if (!string.IsNullOrEmpty(validation))
|
||||
throw new ArgumentException($"Cannot move '{sourcePath}' to '{dest}': {validation}");
|
||||
|
||||
if (dryRun)
|
||||
return new AuthoringResult { AssetPath = dest, Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name };
|
||||
|
||||
var moveError = AssetDatabase.MoveAsset(sourcePath, dest);
|
||||
if (!string.IsNullOrEmpty(moveError))
|
||||
throw new ArgumentException($"Failed to move '{sourcePath}' to '{dest}': {moveError}");
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(dest);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult();
|
||||
result.AssetPath = dest;
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("copy_asset", "Copy an asset to a new path under the authoring root. The copy gets a fresh GUID.", Tags = new[] { "assets" })]
|
||||
public static AuthoringResult CopyAsset(
|
||||
[CliArg("asset", "Reference to the asset to copy (path / guid / globalId).", Required = true)] ObjectRef asset,
|
||||
[CliArg("destination", "Destination asset path relative to the authoring root, including extension. The Assets/ prefix is optional.", Required = true)] string destination,
|
||||
[CliArg("confirm", "Required (true) only when overwriting an existing asset at the destination path.")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, validate inputs and report what would be copied without writing anything.")] bool dryRun = false)
|
||||
{
|
||||
var sourcePath = ResolveAssetPath(asset);
|
||||
|
||||
var dest = ProjectPaths.Resolve(destination, out var error);
|
||||
if (dest == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
var exists = AssetDatabase.LoadMainAssetAtPath(dest) != null;
|
||||
if (exists && !confirm)
|
||||
throw new ArgumentException($"An asset already exists at '{dest}'. Pass confirm=true to overwrite it.");
|
||||
|
||||
if (dryRun)
|
||||
return new AuthoringResult { AssetPath = dest, Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name };
|
||||
|
||||
EnsureParentFolder(dest);
|
||||
|
||||
// AssetDatabase.CopyAsset fails if the destination already exists, so delete it first when
|
||||
// the caller has confirmed an overwrite (the confirm guard above gates that).
|
||||
if (exists)
|
||||
AssetDatabase.DeleteAsset(dest);
|
||||
|
||||
if (!AssetDatabase.CopyAsset(sourcePath, dest))
|
||||
throw new ArgumentException($"Failed to copy '{sourcePath}' to '{dest}'.");
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(dest);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult();
|
||||
result.AssetPath = dest;
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("rename_asset", "Rename an asset in place (keeps it in the same folder, keeps its GUID).", Tags = new[] { "assets" })]
|
||||
public static AuthoringResult RenameAsset(
|
||||
[CliArg("asset", "Reference to the asset to rename (path / guid / globalId).", Required = true)] ObjectRef asset,
|
||||
[CliArg("new_name", "New file name WITHOUT a folder path. The extension is preserved if omitted.", Required = true)] string newName,
|
||||
[CliArg("dry_run", "If true, validate the rename without performing it.")] bool dryRun = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
throw new ArgumentException("new_name is required.");
|
||||
if (newName.Contains("/") || newName.Contains("\\"))
|
||||
throw new ArgumentException("new_name must be a file name only, not a path. Use move_asset to relocate an asset.");
|
||||
|
||||
var sourcePath = ResolveAssetPath(asset);
|
||||
|
||||
// Preserve the original extension when the agent omits one (AssetDatabase.RenameAsset expects no extension).
|
||||
var bareName = Path.GetFileNameWithoutExtension(newName);
|
||||
var extension = Path.GetExtension(sourcePath);
|
||||
var folder = Path.GetDirectoryName(sourcePath)?.Replace('\\', '/');
|
||||
var destPath = $"{folder}/{bareName}{extension}";
|
||||
|
||||
if (AssetDatabase.LoadMainAssetAtPath(destPath) != null)
|
||||
throw new ArgumentException($"An asset already exists at '{destPath}'.");
|
||||
|
||||
if (dryRun)
|
||||
return new AuthoringResult { AssetPath = destPath, Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name };
|
||||
|
||||
var renameError = AssetDatabase.RenameAsset(sourcePath, bareName);
|
||||
if (!string.IsNullOrEmpty(renameError))
|
||||
throw new ArgumentException($"Failed to rename '{sourcePath}': {renameError}");
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(destPath);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult();
|
||||
result.AssetPath = destPath;
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("delete_asset", "Delete an asset from the project. Destructive: requires confirm=true.", Tags = new[] { "assets" })]
|
||||
public static AuthoringResult DeleteAsset(
|
||||
[CliArg("asset", "Reference to the asset to delete (path / guid / globalId).", Required = true)] ObjectRef asset,
|
||||
[CliArg("confirm", "Must be true to actually delete. Without it the command refuses (destructive guard).")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, report the asset that would be deleted without deleting it.")] bool dryRun = false)
|
||||
{
|
||||
var sourcePath = ResolveAssetPath(asset);
|
||||
|
||||
// Capture the identity before deletion so the agent gets a record of what was removed.
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(sourcePath);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = AssetDatabase.GetMainAssetTypeAtPath(sourcePath)?.Name };
|
||||
result.AssetPath = sourcePath;
|
||||
|
||||
if (dryRun)
|
||||
return result;
|
||||
|
||||
if (!confirm)
|
||||
throw new ArgumentException($"Refusing to delete '{sourcePath}'. Pass confirm=true to delete it (destructive, not undoable via Unity's Undo).");
|
||||
|
||||
if (!AssetDatabase.DeleteAsset(sourcePath))
|
||||
throw new ArgumentException($"Failed to delete '{sourcePath}'.");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("find_assets", "Find assets by type and/or name and/or label, returning their path, GUID and type. At least one filter is required.", Tags = new[] { "assets" })]
|
||||
public static FindAssetsResult FindAssets(
|
||||
[CliArg("type", "Type name to filter by (e.g. Material, GameObject, ScriptableObject, MyGame.GameConfig). Resolved to a System.Type and matched against each asset's actual main type.")] string type = null,
|
||||
[CliArg("name", "Name substring to filter by (AssetDatabase name filter).")] string name = null,
|
||||
[CliArg("label", "Asset label to filter by (AssetDatabase 'l:' filter).")] string label = null,
|
||||
[CliArg("search_in", "Folder to scope the search to, relative to the authoring root (default: the authoring root).")] string searchIn = null,
|
||||
[CliArg("limit", "Maximum number of results to return (default 200).")] int limit = 200)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(type) && string.IsNullOrWhiteSpace(name) && string.IsNullOrWhiteSpace(label))
|
||||
throw new ArgumentException("At least one of type, name, or label is required.");
|
||||
|
||||
// The type filter is applied by post-filtering on each asset's actual loaded type rather
|
||||
// than via AssetDatabase's "t:" token: "t:" does NOT reliably match freshly-created/custom
|
||||
// ScriptableObject assets (it can return 0 even for "t:ScriptableObject"), though it works
|
||||
// for built-in types. Name/label searches do work, so build the query from those only.
|
||||
var filterParts = new System.Collections.Generic.List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
filterParts.Add(name.Trim());
|
||||
if (!string.IsNullOrWhiteSpace(label))
|
||||
filterParts.Add($"l:{label.Trim()}");
|
||||
var filter = string.Join(" ", filterParts);
|
||||
|
||||
// Default the search scope to the authoring root (not the whole project) so an agent only
|
||||
// ever discovers assets within its sandbox unless an explicit scope is given.
|
||||
var scopeError = (string)null;
|
||||
var scope = !string.IsNullOrWhiteSpace(searchIn)
|
||||
? ProjectPaths.Resolve(searchIn, out scopeError)
|
||||
: ProjectPaths.AuthoringRoot;
|
||||
if (scope == null)
|
||||
throw new ArgumentException(scopeError);
|
||||
var searchFolders = new[] { scope };
|
||||
|
||||
// Resolve the type filter up front so an unresolvable name fails fast with a clear message.
|
||||
Type typeFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(type))
|
||||
{
|
||||
typeFilter = ResolveTypeName(type.Trim());
|
||||
if (typeFilter == null)
|
||||
throw new ArgumentException(
|
||||
$"Could not resolve type '{type}'. Use a short name (e.g. Material) or a fully-qualified name (e.g. MyGame.GameConfig).");
|
||||
}
|
||||
|
||||
// An empty name/label filter enumerates every asset under the search folders.
|
||||
var guids = AssetDatabase.FindAssets(filter ?? string.Empty, searchFolders);
|
||||
|
||||
var result = new FindAssetsResult { Filter = filter };
|
||||
foreach (var guid in guids)
|
||||
{
|
||||
var assetPath = AssetDatabase.GUIDToAssetPath(guid);
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
continue;
|
||||
|
||||
var mainType = AssetDatabase.GetMainAssetTypeAtPath(assetPath);
|
||||
if (typeFilter != null && (mainType == null || !typeFilter.IsAssignableFrom(mainType)))
|
||||
continue;
|
||||
|
||||
if (result.Assets.Count >= limit)
|
||||
{
|
||||
result.Truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
result.Assets.Add(new AuthoringResult
|
||||
{
|
||||
AssetPath = assetPath,
|
||||
Guid = guid,
|
||||
Type = mainType?.Name
|
||||
});
|
||||
}
|
||||
|
||||
result.Count = result.Assets.Count;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a user-supplied type name (short or fully-qualified) to a <see cref="Type"/> by
|
||||
/// scanning non-dynamic loaded assemblies. Returns null when no match is found.
|
||||
/// </summary>
|
||||
private static Type ResolveTypeName(string typeName)
|
||||
{
|
||||
var direct = Type.GetType(typeName, throwOnError: false);
|
||||
if (direct != null)
|
||||
return direct;
|
||||
|
||||
foreach (var assembly in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
if (assembly.IsDynamic)
|
||||
continue;
|
||||
|
||||
var byFullName = assembly.GetType(typeName, throwOnError: false);
|
||||
if (byFullName != null)
|
||||
return byFullName;
|
||||
}
|
||||
|
||||
foreach (var assembly in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
if (assembly.IsDynamic)
|
||||
continue;
|
||||
|
||||
Type[] types;
|
||||
try
|
||||
{
|
||||
types = assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
types = ex.Types;
|
||||
}
|
||||
|
||||
foreach (var candidate in types)
|
||||
{
|
||||
if (candidate != null && candidate.Name == typeName)
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve an <see cref="ObjectRef"/> to a project-relative asset path, rejecting handles that
|
||||
/// do not resolve to an on-disk asset (scene objects have no asset path). The resolved path is
|
||||
/// confined to the authoring root so a GUID/globalId handle cannot make a destructive command
|
||||
/// (delete/move/copy/rename) operate on an asset outside the sandbox.
|
||||
/// </summary>
|
||||
private static string ResolveAssetPath(ObjectRef asset)
|
||||
{
|
||||
var assetPath = ObjectResolver.TryResolve(asset, out var obj, out var error)
|
||||
? AssetDatabase.GetAssetPath(obj)
|
||||
: null;
|
||||
|
||||
// Fallback: a path reference that points at an asset which exists on disk (a GUID is
|
||||
// registered) but whose object cannot be loaded — e.g. a just-moved asset whose object
|
||||
// isn't reloadable this frame, or an asset with an unresolved/broken script. We can still
|
||||
// operate on it by path, so honor the path rather than failing the whole command.
|
||||
if (string.IsNullOrEmpty(assetPath) && !string.IsNullOrEmpty(asset?.Path))
|
||||
{
|
||||
var candidate = ProjectPaths.Resolve(asset.Path, out _);
|
||||
if (candidate != null && !string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(candidate)))
|
||||
assetPath = candidate;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
throw new ArgumentException(error ?? $"Reference '{asset}' does not point at an on-disk asset.");
|
||||
|
||||
// Confine to the authoring root. ProjectPaths.Resolve treats explicit "Assets/..." paths as
|
||||
// project-relative and rejects anything outside the configured root.
|
||||
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
|
||||
if (confined == null)
|
||||
throw new ArgumentException(
|
||||
$"Asset '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
|
||||
|
||||
return confined;
|
||||
}
|
||||
|
||||
/// <summary>Create the asset's parent folder chain if it does not yet exist.</summary>
|
||||
private static void EnsureParentFolder(string assetPath)
|
||||
{
|
||||
var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent))
|
||||
return;
|
||||
|
||||
CreateFolderRecursive(parent);
|
||||
}
|
||||
|
||||
private static void CreateFolderRecursive(string assetsPath)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(assetsPath))
|
||||
return;
|
||||
|
||||
var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/');
|
||||
var name = Path.GetFileName(assetsPath);
|
||||
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
|
||||
throw new ArgumentException($"Invalid folder path '{assetsPath}'.");
|
||||
|
||||
if (!AssetDatabase.IsValidFolder(parent))
|
||||
CreateFolderRecursive(parent);
|
||||
|
||||
AssetDatabase.CreateFolder(parent, name);
|
||||
}
|
||||
|
||||
/// <summary>Convert a project-relative asset path to an absolute filesystem path.</summary>
|
||||
private static string ToAbsolute(string projectRelative)
|
||||
{
|
||||
return $"{ProjectPaths.ProjectRoot.Replace('\\', '/')}/{projectRelative}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// CLI-221: resolve the shader to assign to a newly-created <see cref="Material"/>.
|
||||
/// - An explicit <paramref name="shaderName"/> must resolve via <see cref="Shader.Find"/>;
|
||||
/// a miss is a clear, actionable error (no null Material / null-ref downstream).
|
||||
/// - When omitted, default to the active render pipeline's lit shader: "Universal Render
|
||||
/// Pipeline/Lit" when an SRP asset is active, else built-in "Standard". If that default
|
||||
/// can't be found, fall back to "Standard"; if even that is missing, error clearly.
|
||||
/// </summary>
|
||||
private static Shader ResolveShader(string shaderName)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(shaderName))
|
||||
{
|
||||
var requested = Shader.Find(shaderName.Trim());
|
||||
if (requested == null)
|
||||
throw new ArgumentException($"Shader '{shaderName}' not found.");
|
||||
return requested;
|
||||
}
|
||||
|
||||
var usingSrp = UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline != null;
|
||||
var defaultName = usingSrp ? "Universal Render Pipeline/Lit" : "Standard";
|
||||
|
||||
var defaultShader = Shader.Find(defaultName);
|
||||
if (defaultShader != null)
|
||||
return defaultShader;
|
||||
|
||||
// Fall back to the built-in Standard shader (always present in the Built-in RP, and a sane
|
||||
// last resort even under an SRP project that lacks the URP/Lit shader).
|
||||
var standard = Shader.Find("Standard");
|
||||
if (standard != null)
|
||||
return standard;
|
||||
|
||||
throw new ArgumentException(
|
||||
$"Could not resolve a default shader ('{defaultName}' or 'Standard'). Pass an explicit shader= argument.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a user-supplied type name to a <see cref="Type"/>. Tries the name as-is (covers
|
||||
/// fully-qualified names), then scans loaded assemblies for an exact full-name or short-name
|
||||
/// match (covers "Material" or a user type given without its namespace).
|
||||
/// </summary>
|
||||
private static Type ResolveType(string typeName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(typeName))
|
||||
return null;
|
||||
|
||||
// Trim once up front so every resolution path below (special-case + assembly/type scans)
|
||||
// sees the same normalized name — leading/trailing whitespace must not be accepted for one
|
||||
// type and rejected for another.
|
||||
typeName = typeName.Trim();
|
||||
|
||||
// CLI-222: in Unity 6 the type is UnityEngine.PhysicsMaterial (renamed from PhysicMaterial).
|
||||
// A short name "PhysicsMaterial" resolves fine, but the legacy short name "PhysicMaterial"
|
||||
// matches the obsolete forwarder/alias inconsistently across versions — normalize BOTH the
|
||||
// current and legacy names (short or fully-qualified) to the real type up front so callers
|
||||
// can use either spelling.
|
||||
if (typeName == "PhysicsMaterial" || typeName == "PhysicMaterial"
|
||||
|| typeName == "UnityEngine.PhysicsMaterial" || typeName == "UnityEngine.PhysicMaterial")
|
||||
{
|
||||
return typeof(PhysicsMaterial);
|
||||
}
|
||||
|
||||
var direct = Type.GetType(typeName, throwOnError: false);
|
||||
if (direct != null)
|
||||
return direct;
|
||||
|
||||
foreach (var assembly in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
var byFullName = assembly.GetType(typeName, throwOnError: false);
|
||||
if (byFullName != null)
|
||||
return byFullName;
|
||||
}
|
||||
|
||||
// Fall back to a short-name match across all loaded types.
|
||||
foreach (var assembly in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
Type[] types;
|
||||
try
|
||||
{
|
||||
types = assembly.GetTypes();
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
types = ex.Types;
|
||||
}
|
||||
|
||||
foreach (var candidate in types)
|
||||
{
|
||||
if (candidate != null && candidate.Name == typeName)
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 47b49bf20465486fb6ffdf6c03775b16
|
||||
@@ -0,0 +1,660 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
using Unity.Scripting.LifecycleManagement;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Assets
|
||||
{
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// Import-settings authoring commands (CLI-191 + CLI-212). Reads and edits the
|
||||
/// <see cref="AssetImporter"/> for an asset.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <c>set_import_settings</c> sets named members on the importer from a JSON object and re-imports.
|
||||
/// For the <c>Default</c> platform it sets top-level public properties/fields via reflection (works
|
||||
/// across every importer kind). For a real platform on a <see cref="TextureImporter"/> /
|
||||
/// <see cref="AudioImporter"/>, keys are applied onto the platform-override struct
|
||||
/// (<see cref="TextureImporterPlatformSettings"/> / <see cref="AudioImporterSampleSettings"/>) which
|
||||
/// is unreachable by top-level reflection. <see cref="ModelImporter"/> has no per-platform overrides.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <c>get_import_settings</c> reads the importer state structured by importer type (texture / model /
|
||||
/// audio), including the default-platform fields and, for textures/audio, one platform override block.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Unknown keys are reported back rather than silently ignored, so an agent gets actionable feedback.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// NOTE: import settings live in the asset's .meta file via the importer; this is an AssetDatabase
|
||||
/// operation and is not part of Unity's Undo system. SaveAndReimport is not destructive to the
|
||||
/// source file, so no <c>confirm</c> is required.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
[NoAutoStaticsCleanup]
|
||||
#endif
|
||||
static class AssetImportCommands
|
||||
{
|
||||
// Caller-facing platform names accepted by both commands. "Default" means the default platform
|
||||
// (top-level importer properties / default sample settings); the rest are real build platforms.
|
||||
private static readonly HashSet<string> s_SupportedPlatforms = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Default", "Standalone", "iOS", "Android", "WebGL", "tvOS"
|
||||
};
|
||||
|
||||
[CliCommand("set_import_settings", "Set import settings on an asset's AssetImporter (default platform top-level properties, or a texture/audio per-platform override) and re-import it.", Tags = new[] { "assets/import" })]
|
||||
public static SetImportSettingsResult SetImportSettings(
|
||||
[CliArg("asset", "Reference to the asset whose importer to edit (path / guid / globalId).", Required = true)] ObjectRef asset,
|
||||
[CliArg("settings", "JSON object of importer property/field names to values, e.g. {\"isReadable\": true, \"textureType\": \"NormalMap\"}. For platform != Default on a texture/audio importer, keys map onto the platform-settings struct (e.g. maxTextureSize, format, compressionFormat, quality, and overridden).", Required = true)] JObject settings,
|
||||
[CliArg("platform", "Target platform: Default | Standalone | iOS | Android | WebGL | tvOS. Defaults to Default (top-level importer properties). A real platform writes a per-platform override (textures/audio only).")] string platform = "Default",
|
||||
[CliArg("dry_run", "If true, validate which settings would apply (and which are unknown) without writing or re-importing.")] bool dryRun = false)
|
||||
{
|
||||
var importer = ResolveImporter(asset, out var assetPath);
|
||||
|
||||
if (settings == null || settings.Count == 0)
|
||||
throw new ArgumentException("settings must be a non-empty JSON object.");
|
||||
|
||||
if (!s_SupportedPlatforms.Contains(platform))
|
||||
throw new ArgumentException(
|
||||
Serialize(new ErrorResult { Code = "unknown_platform", Message = $"Unknown platform '{platform}'. Supported: {string.Join(", ", s_SupportedPlatforms)}." }));
|
||||
|
||||
var canonicalPlatform = Canonical(platform);
|
||||
var isDefault = string.Equals(canonicalPlatform, "Default", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var result = new SetImportSettingsResult
|
||||
{
|
||||
AssetPath = assetPath,
|
||||
ImporterType = importer.GetType().Name,
|
||||
Platform = canonicalPlatform
|
||||
};
|
||||
|
||||
// Strip the server-injected token before iterating: it is never an importer setting.
|
||||
var keys = new List<string>();
|
||||
foreach (var pair in settings)
|
||||
{
|
||||
if (pair.Key == "token")
|
||||
continue;
|
||||
keys.Add(pair.Key);
|
||||
}
|
||||
if (keys.Count == 0)
|
||||
throw new ArgumentException("settings must be a non-empty JSON object.");
|
||||
|
||||
if (isDefault)
|
||||
{
|
||||
ApplyDefaultPlatform(importer, settings, keys, result, write: !dryRun);
|
||||
}
|
||||
else
|
||||
{
|
||||
switch (importer)
|
||||
{
|
||||
case TextureImporter texture:
|
||||
ApplyTexturePlatform(texture, settings, keys, canonicalPlatform, result, write: !dryRun);
|
||||
break;
|
||||
case AudioImporter audio:
|
||||
ApplyAudioPlatform(audio, settings, keys, canonicalPlatform, result, write: !dryRun);
|
||||
break;
|
||||
case ModelImporter _:
|
||||
throw new ArgumentException(
|
||||
Serialize(new ErrorResult { Code = "no_platform_overrides", Message = "ModelImporter has no per-platform overrides; use platform=Default." }));
|
||||
default:
|
||||
throw new ArgumentException(
|
||||
Serialize(new ErrorResult { Code = "no_platform_overrides", Message = $"Importer '{result.ImporterType}' has no per-platform overrides; use platform=Default." }));
|
||||
}
|
||||
}
|
||||
|
||||
if (result.Unknown.Count > 0 && result.Applied.Count == 0)
|
||||
throw new ArgumentException($"None of the supplied settings could be applied to '{result.ImporterType}' (unknown key or invalid value): {string.Join(", ", result.Unknown)}.");
|
||||
|
||||
if (dryRun)
|
||||
return result;
|
||||
|
||||
importer.SaveAndReimport();
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("get_import_settings", "Read an asset's import settings, structured by importer type (texture/model/audio), including the default-platform fields and (for textures/audio) one platform override block.", Tags = new[] { "assets/import" })]
|
||||
public static GetImportSettingsResult GetImportSettings(
|
||||
[CliArg("asset", "Reference to the asset whose importer to read (path / guid / globalId).", Required = true)] ObjectRef asset,
|
||||
[CliArg("platform", "Platform whose override to read: Default | Standalone | iOS | Android | WebGL | tvOS. Defaults to Default.")] string platform = "Default")
|
||||
{
|
||||
var importer = ResolveImporter(asset, out var assetPath);
|
||||
|
||||
if (!s_SupportedPlatforms.Contains(platform))
|
||||
throw new ArgumentException(
|
||||
Serialize(new ErrorResult { Code = "unknown_platform", Message = $"Unknown platform '{platform}'. Supported: {string.Join(", ", s_SupportedPlatforms)}." }));
|
||||
|
||||
var canonicalPlatform = Canonical(platform);
|
||||
var isDefault = string.Equals(canonicalPlatform, "Default", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var result = new GetImportSettingsResult
|
||||
{
|
||||
AssetPath = assetPath,
|
||||
ImporterType = importer.GetType().Name,
|
||||
Platform = canonicalPlatform
|
||||
};
|
||||
|
||||
switch (importer)
|
||||
{
|
||||
case TextureImporter texture:
|
||||
result.Settings = ReadTextureDefaults(texture);
|
||||
result.PlatformOverride = ReadTexturePlatformOverride(texture, canonicalPlatform, isDefault);
|
||||
break;
|
||||
case ModelImporter model:
|
||||
result.Settings = ReadModelDefaults(model);
|
||||
result.PlatformOverride = null; // ModelImporter has no per-platform overrides.
|
||||
break;
|
||||
case AudioImporter audio:
|
||||
result.Settings = ReadAudioDefaults(audio);
|
||||
result.PlatformOverride = ReadAudioPlatformOverride(audio, canonicalPlatform, isDefault);
|
||||
break;
|
||||
default:
|
||||
// Non-import asset (e.g. a ScriptableObject via NativeFormatImporter): return a
|
||||
// minimal settings block rather than throwing.
|
||||
result.Settings = new Dictionary<string, object>
|
||||
{
|
||||
["userData"] = importer.userData,
|
||||
["assetBundleName"] = importer.assetBundleName
|
||||
};
|
||||
result.PlatformOverride = null;
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ resolution / helpers
|
||||
|
||||
/// <summary>
|
||||
/// Resolve an <see cref="ObjectRef"/> to its <see cref="AssetImporter"/>, confined to the
|
||||
/// authoring root. Throws an actionable <see cref="ArgumentException"/> on any failure.
|
||||
/// </summary>
|
||||
private static AssetImporter ResolveImporter(ObjectRef asset, out string assetPath)
|
||||
{
|
||||
if (!ObjectResolver.TryResolve(asset, out var obj, out var error))
|
||||
throw new ArgumentException(error);
|
||||
|
||||
assetPath = AssetDatabase.GetAssetPath(obj);
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
throw new ArgumentException($"Reference '{asset}' does not point at an on-disk asset.");
|
||||
|
||||
// Confine to the authoring root so a GUID/globalId handle cannot reach an asset outside the
|
||||
// sandbox.
|
||||
assetPath = ProjectPaths.Resolve(assetPath, out var confineError);
|
||||
if (assetPath == null)
|
||||
throw new ArgumentException(
|
||||
$"Asset '{asset}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
|
||||
|
||||
var importer = AssetImporter.GetAtPath(assetPath);
|
||||
if (importer == null)
|
||||
throw new ArgumentException($"No AssetImporter found for '{assetPath}'.");
|
||||
|
||||
return importer;
|
||||
}
|
||||
|
||||
/// <summary>Normalize a caller platform name to its canonical capitalization (e.g. "ios" -> "iOS").</summary>
|
||||
private static string Canonical(string platform)
|
||||
{
|
||||
foreach (var supported in s_SupportedPlatforms)
|
||||
{
|
||||
if (string.Equals(supported, platform, StringComparison.OrdinalIgnoreCase))
|
||||
return supported;
|
||||
}
|
||||
return platform;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a caller-facing platform name to the build-platform string Unity's importer APIs expect.
|
||||
/// Most match 1:1; iOS is "iPhone" to those APIs.
|
||||
/// </summary>
|
||||
private static string UnityPlatformName(string canonicalPlatform)
|
||||
{
|
||||
return string.Equals(canonicalPlatform, "iOS", StringComparison.OrdinalIgnoreCase) ? "iPhone" : canonicalPlatform;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ default-platform reflection
|
||||
|
||||
private static void ApplyDefaultPlatform(AssetImporter importer, JObject settings, List<string> keys, SetImportSettingsResult result, bool write)
|
||||
{
|
||||
var importerType = importer.GetType();
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
// In dry_run we only validate the member exists and the value converts — never mutate the
|
||||
// (possibly cached) importer instance, so a dry run leaves no trace.
|
||||
var applied = TryApplyMember(importer, importerType, flags, key, settings[key], write);
|
||||
if (applied)
|
||||
result.Applied.Add(key);
|
||||
else
|
||||
result.Unknown.Add(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set a single named property or field on the target from a JSON token. Returns false when no
|
||||
/// writable member with that name exists or the value cannot be converted to the member type.
|
||||
/// Enum members accept the value name (case-insensitive) as well as numeric values.
|
||||
/// </summary>
|
||||
private static bool TryApplyMember(object target, Type type, BindingFlags flags, string memberName, JToken value, bool write)
|
||||
{
|
||||
var property = type.GetProperty(memberName, flags);
|
||||
if (property != null && property.CanWrite && property.GetIndexParameters().Length == 0)
|
||||
{
|
||||
return TryConvertAndSet(property.PropertyType, value,
|
||||
converted => { if (write) property.SetValue(target, converted); });
|
||||
}
|
||||
|
||||
var field = type.GetField(memberName, flags);
|
||||
if (field != null)
|
||||
{
|
||||
return TryConvertAndSet(field.FieldType, value,
|
||||
converted => { if (write) field.SetValue(target, converted); });
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a JSON token to <paramref name="targetType"/> (with case-insensitive enum-name support)
|
||||
/// and, on success, invoke <paramref name="set"/>. Returns false when the value cannot convert.
|
||||
/// </summary>
|
||||
private static bool TryConvertAndSet(Type targetType, JToken value, Action<object> set)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TryConvert(targetType, value, out var converted))
|
||||
return false;
|
||||
set(converted);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert a JSON token to a CLR value. Enums accept their value name (case-insensitive) or an
|
||||
/// integral value; everything else defers to Newtonsoft's <see cref="JToken.ToObject(Type)"/>.
|
||||
/// </summary>
|
||||
private static bool TryConvert(Type targetType, JToken value, out object converted)
|
||||
{
|
||||
converted = null;
|
||||
var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType;
|
||||
|
||||
if (underlying.IsEnum)
|
||||
{
|
||||
if (value.Type == JTokenType.String)
|
||||
{
|
||||
var name = value.Value<string>();
|
||||
if (string.IsNullOrEmpty(name))
|
||||
return false;
|
||||
try
|
||||
{
|
||||
converted = Enum.Parse(underlying, name, ignoreCase: true);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (value.Type == JTokenType.Integer)
|
||||
{
|
||||
converted = Enum.ToObject(underlying, value.Value<long>());
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
converted = value.ToObject(targetType);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ texture platform overrides
|
||||
|
||||
private static void ApplyTexturePlatform(TextureImporter texture, JObject settings, List<string> keys, string canonicalPlatform, SetImportSettingsResult result, bool write)
|
||||
{
|
||||
var unityPlatform = UnityPlatformName(canonicalPlatform);
|
||||
// Boxed struct so reflection SetValue mutates the local copy; written back via SetPlatformTextureSettings.
|
||||
object boxed = texture.GetPlatformTextureSettings(unityPlatform);
|
||||
var type = typeof(TextureImporterPlatformSettings);
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
|
||||
|
||||
// Only treat "overridden" as explicitly provided when the caller passes a valid boolean.
|
||||
// A non-boolean value is caught by TrySetBoolMember (reported to unknown[]), so we must
|
||||
// not suppress the default overridden=true in that case or the override is silently skipped.
|
||||
var explicitOverridden = settings.TryGetValue("overridden", StringComparison.OrdinalIgnoreCase, out var overriddenTok)
|
||||
&& overriddenTok.Type == JTokenType.Boolean;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (string.Equals(key, "overridden", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Handled explicitly below; still report it as applied.
|
||||
if (TrySetBoolMember(type, flags, boxed, "overridden", settings[key], write))
|
||||
result.Applied.Add(key);
|
||||
else
|
||||
result.Unknown.Add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryApplyMember(boxed, type, flags, key, settings[key], write))
|
||||
result.Applied.Add(key);
|
||||
else
|
||||
result.Unknown.Add(key);
|
||||
}
|
||||
|
||||
// Default the override on (the caller intends to set platform-specific values) unless they
|
||||
// explicitly passed overridden:false (in which case the value they passed is already in the
|
||||
// boxed copy from the loop above).
|
||||
if (!explicitOverridden)
|
||||
SetBool(type, flags, boxed, "overridden", true);
|
||||
|
||||
if (write && result.Applied.Count > 0)
|
||||
{
|
||||
var settingsStruct = (TextureImporterPlatformSettings)boxed;
|
||||
settingsStruct.name = unityPlatform;
|
||||
texture.SetPlatformTextureSettings(settingsStruct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set a named boolean member from a JSON token. Returns false when the token is not a boolean or
|
||||
/// no settable bool member with that name exists, so a non-existent member is reported as unknown
|
||||
/// rather than falsely applied. In <paramref name="write"/>=false (dry-run) mode the member is only
|
||||
/// probed for existence, never mutated.
|
||||
/// </summary>
|
||||
private static bool TrySetBoolMember(Type type, BindingFlags flags, object boxed, string member, JToken token, bool write)
|
||||
{
|
||||
if (token.Type != JTokenType.Boolean)
|
||||
return false;
|
||||
if (!write)
|
||||
return BoolMemberExists(type, flags, member);
|
||||
return SetBool(type, flags, boxed, member, token.Value<bool>());
|
||||
}
|
||||
|
||||
/// <summary>True when a settable boolean property or field with that name exists on the type.</summary>
|
||||
private static bool BoolMemberExists(Type type, BindingFlags flags, string member)
|
||||
{
|
||||
var property = type.GetProperty(member, flags);
|
||||
if (property != null && property.CanWrite && property.PropertyType == typeof(bool)
|
||||
&& property.GetIndexParameters().Length == 0)
|
||||
return true;
|
||||
|
||||
var field = type.GetField(member, flags);
|
||||
return field != null && field.FieldType == typeof(bool);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write a boolean property or field (matching <see cref="TryApplyMember"/>'s property-then-field
|
||||
/// resolution so a bool implemented as a property is also honored). Returns false — without
|
||||
/// mutating anything — when no settable bool member with that name exists.
|
||||
/// </summary>
|
||||
private static bool SetBool(Type type, BindingFlags flags, object boxed, string member, bool value)
|
||||
{
|
||||
var property = type.GetProperty(member, flags);
|
||||
if (property != null && property.CanWrite && property.PropertyType == typeof(bool)
|
||||
&& property.GetIndexParameters().Length == 0)
|
||||
{
|
||||
property.SetValue(boxed, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
var field = type.GetField(member, flags);
|
||||
if (field != null && field.FieldType == typeof(bool))
|
||||
{
|
||||
field.SetValue(boxed, value);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> ReadTextureDefaults(TextureImporter texture)
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
["textureType"] = texture.textureType.ToString(),
|
||||
["textureShape"] = texture.textureShape.ToString(),
|
||||
["sRGBTexture"] = texture.sRGBTexture,
|
||||
["alphaSource"] = texture.alphaSource.ToString(),
|
||||
["alphaIsTransparency"] = texture.alphaIsTransparency,
|
||||
["isReadable"] = texture.isReadable,
|
||||
["streamingMipmaps"] = texture.streamingMipmaps,
|
||||
["mipmapEnabled"] = texture.mipmapEnabled,
|
||||
["wrapMode"] = texture.wrapMode.ToString(),
|
||||
["filterMode"] = texture.filterMode.ToString(),
|
||||
["anisoLevel"] = texture.anisoLevel,
|
||||
["spriteImportMode"] = texture.spriteImportMode.ToString(),
|
||||
["spritePixelsPerUnit"] = texture.spritePixelsPerUnit,
|
||||
["npotScale"] = texture.npotScale.ToString(),
|
||||
["maxTextureSize"] = texture.maxTextureSize
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> ReadTexturePlatformOverride(TextureImporter texture, string canonicalPlatform, bool isDefault)
|
||||
{
|
||||
var settings = isDefault
|
||||
? texture.GetDefaultPlatformTextureSettings()
|
||||
: texture.GetPlatformTextureSettings(UnityPlatformName(canonicalPlatform));
|
||||
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
["overridden"] = !isDefault && settings.overridden,
|
||||
["maxTextureSize"] = settings.maxTextureSize,
|
||||
["resizeAlgorithm"] = settings.resizeAlgorithm.ToString(),
|
||||
["format"] = settings.format.ToString(),
|
||||
["textureCompression"] = settings.textureCompression.ToString(),
|
||||
["compressionQuality"] = settings.compressionQuality,
|
||||
["crunchedCompression"] = settings.crunchedCompression,
|
||||
["androidETC2FallbackOverride"] = settings.androidETC2FallbackOverride.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ audio platform overrides
|
||||
|
||||
private static void ApplyAudioPlatform(AudioImporter audio, JObject settings, List<string> keys, string canonicalPlatform, SetImportSettingsResult result, bool write)
|
||||
{
|
||||
var unityPlatform = UnityPlatformName(canonicalPlatform);
|
||||
|
||||
// overridden:false clears the override entirely (and ignores any other keys).
|
||||
if (settings.TryGetValue("overridden", StringComparison.OrdinalIgnoreCase, out var overriddenToken)
|
||||
&& overriddenToken.Type == JTokenType.Boolean
|
||||
&& !overriddenToken.Value<bool>())
|
||||
{
|
||||
result.Applied.Add("overridden");
|
||||
if (write)
|
||||
audio.ClearSampleSettingOverride(unityPlatform);
|
||||
return;
|
||||
}
|
||||
|
||||
object boxed = audio.GetOverrideSampleSettings(unityPlatform);
|
||||
var type = typeof(AudioImporterSampleSettings);
|
||||
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (string.Equals(key, "overridden", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Only accept a boolean; anything else is a misuse and goes to unknown[].
|
||||
if (settings[key].Type == JTokenType.Boolean)
|
||||
result.Applied.Add(key);
|
||||
else
|
||||
result.Unknown.Add(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryApplyMember(boxed, type, flags, key, settings[key], write))
|
||||
result.Applied.Add(key);
|
||||
else
|
||||
result.Unknown.Add(key);
|
||||
}
|
||||
|
||||
if (write && result.Applied.Count > 0)
|
||||
{
|
||||
var settingsStruct = (AudioImporterSampleSettings)boxed;
|
||||
audio.SetOverrideSampleSettings(unityPlatform, settingsStruct);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> ReadAudioDefaults(AudioImporter audio)
|
||||
{
|
||||
var sample = audio.defaultSampleSettings;
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
["forceToMono"] = audio.forceToMono,
|
||||
["loadInBackground"] = audio.loadInBackground,
|
||||
["ambisonic"] = audio.ambisonic,
|
||||
["loadType"] = sample.loadType.ToString(),
|
||||
["compressionFormat"] = sample.compressionFormat.ToString(),
|
||||
["quality"] = sample.quality,
|
||||
["sampleRateSetting"] = sample.sampleRateSetting.ToString(),
|
||||
["sampleRateOverride"] = sample.sampleRateOverride
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> ReadAudioPlatformOverride(AudioImporter audio, string canonicalPlatform, bool isDefault)
|
||||
{
|
||||
if (isDefault)
|
||||
{
|
||||
var sample = audio.defaultSampleSettings;
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
["overridden"] = false,
|
||||
["loadType"] = sample.loadType.ToString(),
|
||||
["compressionFormat"] = sample.compressionFormat.ToString(),
|
||||
["quality"] = sample.quality,
|
||||
["sampleRateSetting"] = sample.sampleRateSetting.ToString(),
|
||||
["sampleRateOverride"] = sample.sampleRateOverride
|
||||
};
|
||||
}
|
||||
|
||||
var unityPlatform = UnityPlatformName(canonicalPlatform);
|
||||
var overridden = audio.ContainsSampleSettingsOverride(unityPlatform);
|
||||
var settings = audio.GetOverrideSampleSettings(unityPlatform);
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
["overridden"] = overridden,
|
||||
["loadType"] = settings.loadType.ToString(),
|
||||
["compressionFormat"] = settings.compressionFormat.ToString(),
|
||||
["quality"] = settings.quality,
|
||||
["sampleRateSetting"] = settings.sampleRateSetting.ToString(),
|
||||
["sampleRateOverride"] = settings.sampleRateOverride
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ model defaults (no overrides)
|
||||
|
||||
private static Dictionary<string, object> ReadModelDefaults(ModelImporter model)
|
||||
{
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
// Meshes
|
||||
["globalScale"] = model.globalScale,
|
||||
["useFileScale"] = model.useFileScale,
|
||||
["meshCompression"] = model.meshCompression.ToString(),
|
||||
["isReadable"] = model.isReadable,
|
||||
["meshOptimizationFlags"] = model.meshOptimizationFlags.ToString(),
|
||||
["weldVertices"] = model.weldVertices,
|
||||
["indexFormat"] = model.indexFormat.ToString(),
|
||||
["keepQuads"] = model.keepQuads,
|
||||
// Normals / Tangents
|
||||
["importNormals"] = model.importNormals.ToString(),
|
||||
["normalCalculationMode"] = model.normalCalculationMode.ToString(),
|
||||
["normalSmoothingAngle"] = model.normalSmoothingAngle,
|
||||
["importTangents"] = model.importTangents.ToString(),
|
||||
["importBlendShapes"] = model.importBlendShapes,
|
||||
// Geometry
|
||||
["importCameras"] = model.importCameras,
|
||||
["importLights"] = model.importLights,
|
||||
["swapUVChannels"] = model.swapUVChannels,
|
||||
["generateSecondaryUV"] = model.generateSecondaryUV,
|
||||
// Materials
|
||||
["materialImportMode"] = model.materialImportMode.ToString(),
|
||||
["materialLocation"] = model.materialLocation.ToString(),
|
||||
// Animation
|
||||
["importAnimation"] = model.importAnimation,
|
||||
["animationType"] = model.animationType.ToString(),
|
||||
["importConstraints"] = model.importConstraints,
|
||||
["importVisibility"] = model.importVisibility
|
||||
};
|
||||
}
|
||||
|
||||
private static string Serialize(object value)
|
||||
{
|
||||
return Newtonsoft.Json.JsonConvert.SerializeObject(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <c>set_import_settings</c>: target platform plus which keys were applied vs. unknown.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
class SetImportSettingsResult
|
||||
{
|
||||
[Newtonsoft.Json.JsonProperty("assetPath")]
|
||||
public string AssetPath { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("importerType")]
|
||||
public string ImporterType { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("platform")]
|
||||
public string Platform { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("applied")]
|
||||
public List<string> Applied { get; set; } = new List<string>();
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("unknown")]
|
||||
public List<string> Unknown { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <c>get_import_settings</c>: the importer type, default-platform settings, and (for
|
||||
/// textures/audio) one platform-override block. <c>platformOverride</c> is null for ModelImporter and
|
||||
/// non-import assets.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
class GetImportSettingsResult
|
||||
{
|
||||
[Newtonsoft.Json.JsonProperty("assetPath")]
|
||||
public string AssetPath { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("importerType")]
|
||||
public string ImporterType { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("settings")]
|
||||
public Dictionary<string, object> Settings { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("platform")]
|
||||
public string Platform { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("platformOverride")]
|
||||
public Dictionary<string, object> PlatformOverride { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Structured error payload serialized into the thrown ArgumentException message.</summary>
|
||||
[Serializable]
|
||||
class ErrorResult
|
||||
{
|
||||
[Newtonsoft.Json.JsonProperty("code")]
|
||||
public string Code { get; set; }
|
||||
|
||||
[Newtonsoft.Json.JsonProperty("message")]
|
||||
public string Message { get; set; }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cb5b302104dc446d8d94f33252087062
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Models;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Assets
|
||||
{
|
||||
/// <summary>
|
||||
/// Structured result for the <c>find_assets</c> command (CLI-191): a list of matched assets, each
|
||||
/// described as the canonical <see cref="AuthoringResult"/> (path / GUID / type) so an agent can
|
||||
/// feed any entry straight back into another command as an <see cref="ObjectRef"/>.
|
||||
///
|
||||
/// Lives in the Editor command assembly (rather than Runtime/Models) because find_assets is an
|
||||
/// Editor-only command and the foundation Runtime/Models are shared/frozen for parallel work.
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
class FindAssetsResult
|
||||
{
|
||||
/// <summary>The AssetDatabase filter string that was executed (for diagnostics).</summary>
|
||||
[JsonProperty("filter")]
|
||||
public string Filter { get; set; }
|
||||
|
||||
/// <summary>Number of assets returned (after the limit is applied).</summary>
|
||||
[JsonProperty("count")]
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>True when the result was capped by the <c>limit</c> argument.</summary>
|
||||
[JsonProperty("truncated")]
|
||||
public bool Truncated { get; set; }
|
||||
|
||||
/// <summary>The matched assets, each with path / GUID / type.</summary>
|
||||
[JsonProperty("assets")]
|
||||
public List<AuthoringResult> Assets { get; set; } = new List<AuthoringResult>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7ff032cead554b7f97cd2467a83abd35
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Assets
|
||||
{
|
||||
/// <summary>
|
||||
/// Asset-folder authoring commands. Reference exemplar for the authoring foundation (CLI-190):
|
||||
/// it validates input through the project-path sandbox (<see cref="ProjectPaths"/>) and returns
|
||||
/// the canonical <see cref="AuthoringResult"/> envelope so an agent can reference the created
|
||||
/// folder in follow-up calls.
|
||||
/// </summary>
|
||||
static class FolderCommands
|
||||
{
|
||||
[CliCommand("create_folder", "Create a folder under the authoring root (creates intermediate folders).", Tags = new[] { "assets" })]
|
||||
public static AuthoringResult CreateFolder(
|
||||
[CliArg("path", "Folder path relative to the authoring root (default Assets/); the Assets/ prefix is optional. e.g. Gameplay/Enemies or Assets/Gameplay/Enemies", Required = true)] string path)
|
||||
{
|
||||
var normalized = ProjectPaths.Resolve(path, out var error);
|
||||
if (normalized == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
CreateFolderRecursive(normalized);
|
||||
AssetDatabase.Refresh();
|
||||
|
||||
var folder = AssetDatabase.LoadAssetAtPath<DefaultAsset>(normalized);
|
||||
var result = ObjectResolver.Describe(folder) ?? new AuthoringResult { Type = "DefaultAsset" };
|
||||
result.AssetPath = normalized;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void CreateFolderRecursive(string assetsPath)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(assetsPath))
|
||||
return;
|
||||
|
||||
var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/');
|
||||
var name = Path.GetFileName(assetsPath);
|
||||
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
|
||||
throw new ArgumentException($"Invalid folder path '{assetsPath}'.");
|
||||
|
||||
if (!AssetDatabase.IsValidFolder(parent))
|
||||
CreateFolderRecursive(parent);
|
||||
|
||||
AssetDatabase.CreateFolder(parent, name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b78420627d9cd1e43835d9088975e691
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Assets
|
||||
{
|
||||
/// <summary>
|
||||
/// Text-file authoring commands (CLI-191): read and write UTF-8 text files inside the authoring
|
||||
/// sandbox. Useful for editing data assets that are plain text (JSON/CSV/.txt/.cs/.shader, etc.).
|
||||
///
|
||||
/// Every path is funnelled through <see cref="ProjectPaths.Resolve"/>, so reads/writes are confined
|
||||
/// to the authoring root and "../"/out-of-project paths are rejected. Writing imports the file back
|
||||
/// into the AssetDatabase so Unity picks up the change.
|
||||
///
|
||||
/// NOTE: writing is potentially destructive (it can overwrite an existing file), so overwriting an
|
||||
/// existing file requires an explicit <c>confirm</c> argument; <c>dry_run</c> is supported.
|
||||
/// Filesystem writes are not part of Unity's Undo system.
|
||||
/// </summary>
|
||||
static class TextFileCommands
|
||||
{
|
||||
[CliCommand("read_text_file", "Read a UTF-8 text file under the authoring root and return its contents.", MainThreadRequired = true, Tags = new[] { "assets/text" })]
|
||||
public static ReadTextFileResult ReadTextFile(
|
||||
[CliArg("path", "Text file path relative to the authoring root. The Assets/ prefix is optional.", Required = true)] string path,
|
||||
[CliArg("max_bytes", "Reject files larger than this many bytes (default 1048576 = 1 MiB) to avoid huge payloads.")] int maxBytes = 1024 * 1024)
|
||||
{
|
||||
var normalized = ProjectPaths.Resolve(path, out var error);
|
||||
if (normalized == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
var absolute = ToAbsolute(normalized);
|
||||
if (!File.Exists(absolute))
|
||||
throw new ArgumentException($"No file at '{normalized}'.");
|
||||
|
||||
var length = new FileInfo(absolute).Length;
|
||||
if (length > maxBytes)
|
||||
throw new ArgumentException($"File '{normalized}' is {length} bytes, which exceeds max_bytes={maxBytes}.");
|
||||
|
||||
return new ReadTextFileResult
|
||||
{
|
||||
AssetPath = normalized,
|
||||
Bytes = (int)length,
|
||||
Contents = File.ReadAllText(absolute)
|
||||
};
|
||||
}
|
||||
|
||||
[CliCommand("write_text_file", "Write UTF-8 text to a file under the authoring root, then import it. Overwriting an existing file requires confirm=true.", Tags = new[] { "assets/text" })]
|
||||
public static AuthoringResult WriteTextFile(
|
||||
[CliArg("path", "Text file path relative to the authoring root, including extension. The Assets/ prefix is optional.", Required = true)] string path,
|
||||
[CliArg("contents", "The full text content to write (replaces the file).", Required = true)] string contents,
|
||||
[CliArg("confirm", "Required (true) only when overwriting an existing file at the path.")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, validate inputs and report what would be written without writing anything.")] bool dryRun = false)
|
||||
{
|
||||
var normalized = ProjectPaths.Resolve(path, out var error);
|
||||
if (normalized == null)
|
||||
throw new ArgumentException(error);
|
||||
|
||||
if (contents == null)
|
||||
throw new ArgumentException("contents is required (use an empty string to write an empty file).");
|
||||
|
||||
var absolute = ToAbsolute(normalized);
|
||||
var exists = File.Exists(absolute);
|
||||
if (exists && !confirm)
|
||||
throw new ArgumentException($"A file already exists at '{normalized}'. Pass confirm=true to overwrite it.");
|
||||
|
||||
if (dryRun)
|
||||
return new AuthoringResult { AssetPath = normalized, Type = "TextAsset" };
|
||||
|
||||
EnsureParentFolder(normalized);
|
||||
File.WriteAllText(absolute, contents);
|
||||
AssetDatabase.ImportAsset(normalized, ImportAssetOptions.ForceUpdate);
|
||||
|
||||
var loaded = AssetDatabase.LoadMainAssetAtPath(normalized);
|
||||
var result = ObjectResolver.Describe(loaded) ?? new AuthoringResult { Type = "TextAsset" };
|
||||
result.AssetPath = normalized;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void EnsureParentFolder(string assetPath)
|
||||
{
|
||||
var parent = Path.GetDirectoryName(assetPath)?.Replace('\\', '/');
|
||||
if (string.IsNullOrEmpty(parent) || AssetDatabase.IsValidFolder(parent))
|
||||
return;
|
||||
|
||||
CreateFolderRecursive(parent);
|
||||
}
|
||||
|
||||
private static void CreateFolderRecursive(string assetsPath)
|
||||
{
|
||||
if (AssetDatabase.IsValidFolder(assetsPath))
|
||||
return;
|
||||
|
||||
var parent = Path.GetDirectoryName(assetsPath)?.Replace('\\', '/');
|
||||
var name = Path.GetFileName(assetsPath);
|
||||
if (string.IsNullOrEmpty(parent) || string.IsNullOrEmpty(name))
|
||||
throw new ArgumentException($"Invalid folder path '{assetsPath}'.");
|
||||
|
||||
if (!AssetDatabase.IsValidFolder(parent))
|
||||
CreateFolderRecursive(parent);
|
||||
|
||||
AssetDatabase.CreateFolder(parent, name);
|
||||
}
|
||||
|
||||
private static string ToAbsolute(string projectRelative)
|
||||
{
|
||||
return $"{ProjectPaths.ProjectRoot.Replace('\\', '/')}/{projectRelative}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Result of <c>read_text_file</c>: the file contents plus its identity and size.</summary>
|
||||
[Serializable]
|
||||
class ReadTextFileResult
|
||||
{
|
||||
[JsonProperty("assetPath")]
|
||||
public string AssetPath { get; set; }
|
||||
|
||||
[JsonProperty("bytes")]
|
||||
public int Bytes { get; set; }
|
||||
|
||||
[JsonProperty("contents")]
|
||||
public string Contents { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f57a6d794d1e4c40976f65ec3774b8ed
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7512e3b6952430f4b8fb5c67dd22f13f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Authoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Commands to inspect and configure the authoring root — the base folder (under Assets/) that
|
||||
/// bare authoring paths resolve against and that authoring writes are confined to. Persisted
|
||||
/// per project; the default is "Assets" (full Assets access).
|
||||
/// </summary>
|
||||
static class AuthoringConfigCommands
|
||||
{
|
||||
[CliCommand("get_authoring_root", "Get the base folder (under Assets/) that bare authoring paths resolve against.", Tags = new[] { "authoring" })]
|
||||
public static object GetAuthoringRoot()
|
||||
{
|
||||
return new { root = ProjectPaths.AuthoringRoot };
|
||||
}
|
||||
|
||||
[CliCommand("set_authoring_root", "Set the base folder (under Assets/) that bare authoring paths resolve against and are confined to. Use 'Assets' for full project access.", Tags = new[] { "authoring" })]
|
||||
public static object SetAuthoringRoot(
|
||||
[CliArg("root", "Project-relative folder under Assets/, e.g. Assets/AgentWork. Use 'Assets' to allow the whole project.", Required = true)] string root)
|
||||
{
|
||||
// Throws ArgumentException for invalid roots (outside Assets/ or containing ".."); the
|
||||
// server surfaces that message to the caller.
|
||||
ProjectPaths.AuthoringRoot = root;
|
||||
return new { root = ProjectPaths.AuthoringRoot };
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c4429604fb18e8347a2e2efdefc49663
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
using Unity.Scripting.LifecycleManagement;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Opt-in mode that keeps the Unity Editor ticking at full rate even when its window is
|
||||
/// unfocused. Unity throttles EditorApplication.update when the editor is in the background,
|
||||
/// which can stall long-running work (HTTP commands, async test runs) driven from the CLI.
|
||||
///
|
||||
/// When enabled, this forces EditorApplication.SignalTick() on every update, which schedules
|
||||
/// the next tick immediately and keeps the loop spinning. It is off by default and changes
|
||||
/// nothing in normal package behavior. Cross-platform: no native/OS calls.
|
||||
///
|
||||
/// Note: the update-loop subscription is static and does not survive a domain reload, but the
|
||||
/// enabled/interval choice is persisted to SessionState and restored by
|
||||
/// <see cref="RestoreFromSession"/> (called from <c>EditorPipelineStartup</c> on every server
|
||||
/// (re)start, including after a recompile). Forcing full-rate ticks uses CPU like a focused editor.
|
||||
/// </summary>
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
[NoAutoStaticsCleanup]
|
||||
#endif
|
||||
static class AutoTickCommand
|
||||
{
|
||||
// Default interval when nothing else applies (fresh editor session, no prior SetAutoTick call).
|
||||
internal const int DefaultIntervalMs = 16;
|
||||
|
||||
// SessionState keys: session-scoped editor state, survives domain reload, dies with the editor.
|
||||
private const string EnabledKey = "Unity.Pipeline.AutoTick.Enabled";
|
||||
private const string IntervalKey = "Unity.Pipeline.AutoTick.IntervalMs";
|
||||
|
||||
private static bool s_Enabled;
|
||||
private static Action s_SignalTick;
|
||||
private static EditorApplication.CallbackFunction s_Pump;
|
||||
private static readonly System.Diagnostics.Stopwatch s_Stopwatch = new System.Diagnostics.Stopwatch();
|
||||
private static long s_IntervalMs;
|
||||
|
||||
internal static bool IsEnabled => s_Enabled;
|
||||
internal static long CurrentIntervalMs => s_IntervalMs;
|
||||
|
||||
[CliCommand("set_autotick", "Keep the editor ticking while unfocused by forcing EditorApplication.SignalTick at a throttled rate", MainThreadRequired = true, Tags = new[] { "editor" })]
|
||||
public static string SetAutoTick(
|
||||
[CliArg("enable", "Enable (true) or disable (false) auto-tick mode")] bool enable = true,
|
||||
[CliArg("interval_ms", "Minimum milliseconds between forced ticks. 0 = every update (max rate, pegs a CPU core). Default 16 (~60Hz).")] int intervalMs = DefaultIntervalMs,
|
||||
[CliArg("persist", "Persist this choice to SessionState so it survives a domain reload. Set false for a one-off/expensive setting (e.g. interval_ms=0) that should revert to the last persisted choice (or the default) after the next recompile instead of sticking for the rest of the session.")] bool persist = true)
|
||||
{
|
||||
var result = ApplyAutoTick(enable, intervalMs);
|
||||
if (!persist)
|
||||
return result.StartsWith("Error:") ? result : result + " (session-only, won't survive a domain reload)";
|
||||
|
||||
if (result.StartsWith("Error:"))
|
||||
return result;
|
||||
|
||||
SessionState.SetBool(EnabledKey, s_Enabled);
|
||||
SessionState.SetInt(IntervalKey, (int)s_IntervalMs);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restore auto-tick to whatever the user last explicitly set via <see cref="SetAutoTick"/>
|
||||
/// this editor session (surviving the domain reload that just wiped the statics). If nothing
|
||||
/// was ever explicitly set this session, apply <paramref name="defaultEnabled"/> at
|
||||
/// <see cref="DefaultIntervalMs"/> instead — this is the only case where the watchdog's
|
||||
/// enabled setting decides auto-tick's state. Call on the main thread (touches SessionState).
|
||||
/// </summary>
|
||||
internal static void RestoreFromSession(bool defaultEnabled)
|
||||
{
|
||||
var storedInterval = SessionState.GetInt(IntervalKey, -1);
|
||||
if (storedInterval < 0)
|
||||
{
|
||||
ApplyAutoTick(defaultEnabled, DefaultIntervalMs);
|
||||
return;
|
||||
}
|
||||
|
||||
var storedEnabled = SessionState.GetBool(EnabledKey, defaultEnabled);
|
||||
ApplyAutoTick(storedEnabled, storedInterval);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only: drop the in-memory state (statics + update-loop subscription) but keep
|
||||
/// SessionState, to simulate a domain reload (statics cleared, SessionState survives).
|
||||
/// </summary>
|
||||
internal static void ResetForTests()
|
||||
{
|
||||
if (s_Pump != null)
|
||||
{
|
||||
EditorApplication.update -= s_Pump;
|
||||
s_Pump = null;
|
||||
}
|
||||
s_Stopwatch.Stop();
|
||||
s_Enabled = false;
|
||||
s_IntervalMs = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only: erase the persisted SessionState copy (unlike <see cref="ResetForTests"/>, which
|
||||
/// deliberately leaves it intact). Use for test isolation, not to simulate a reload.
|
||||
/// </summary>
|
||||
internal static void EraseSessionForTests()
|
||||
{
|
||||
SessionState.EraseInt(IntervalKey);
|
||||
SessionState.EraseBool(EnabledKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only: read the persisted SessionState copy without touching the in-memory statics.
|
||||
/// Returns false if nothing has ever been persisted this session.
|
||||
/// </summary>
|
||||
internal static bool TryGetPersistedSessionForTests(out bool enabled, out long intervalMs)
|
||||
{
|
||||
var storedInterval = SessionState.GetInt(IntervalKey, -1);
|
||||
if (storedInterval < 0)
|
||||
{
|
||||
enabled = false;
|
||||
intervalMs = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
enabled = SessionState.GetBool(EnabledKey, false);
|
||||
intervalMs = storedInterval;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test-only: write the persisted SessionState copy directly, bypassing ApplyAutoTick.
|
||||
/// </summary>
|
||||
internal static void SetPersistedSessionForTests(bool enabled, long intervalMs)
|
||||
{
|
||||
SessionState.SetBool(EnabledKey, enabled);
|
||||
SessionState.SetInt(IntervalKey, (int)intervalMs);
|
||||
}
|
||||
|
||||
private static string ApplyAutoTick(bool enable, int intervalMs)
|
||||
{
|
||||
if (enable && s_Enabled)
|
||||
{
|
||||
s_IntervalMs = Math.Max(0, intervalMs);
|
||||
return $"Auto-tick already enabled (interval updated to {s_IntervalMs}ms)";
|
||||
}
|
||||
if (!enable && !s_Enabled)
|
||||
return "Auto-tick already disabled";
|
||||
|
||||
if (enable)
|
||||
{
|
||||
if (s_SignalTick == null)
|
||||
{
|
||||
var method = typeof(EditorApplication).GetMethod(
|
||||
"SignalTick",
|
||||
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
|
||||
|
||||
if (method == null)
|
||||
return "Error: EditorApplication.SignalTick not found (internal API may have changed)";
|
||||
|
||||
try
|
||||
{
|
||||
s_SignalTick = (Action)Delegate.CreateDelegate(typeof(Action), method);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error: could not bind EditorApplication.SignalTick: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
s_IntervalMs = Math.Max(0, intervalMs);
|
||||
s_Stopwatch.Restart();
|
||||
s_Pump = () =>
|
||||
{
|
||||
if (s_IntervalMs <= 0 || s_Stopwatch.ElapsedMilliseconds >= s_IntervalMs)
|
||||
{
|
||||
s_Stopwatch.Restart();
|
||||
s_SignalTick();
|
||||
}
|
||||
};
|
||||
EditorApplication.update += s_Pump;
|
||||
s_Enabled = true;
|
||||
return $"Auto-tick enabled (interval {s_IntervalMs}ms)";
|
||||
}
|
||||
|
||||
if (s_Pump != null)
|
||||
{
|
||||
EditorApplication.update -= s_Pump;
|
||||
s_Pump = null;
|
||||
}
|
||||
s_Stopwatch.Stop();
|
||||
s_Enabled = false;
|
||||
return "Auto-tick disabled";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76bae7b5ceb53dd498f891ed7f1e0d19
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 758dc2e598a448e5b80e66443fede0d9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Linq;
|
||||
using UnityEngine.SceneManagement;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Baking
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared helpers for the CLI-215 bake commands (lighting / NavMesh / occlusion). All three bakes
|
||||
/// act on the <b>currently open scene(s)</b> — there is no per-asset target — so before triggering
|
||||
/// any bake we verify at least one open, saved scene exists. A bake against no open/saved scene is
|
||||
/// rejected up front with the documented <c>{ code: "no_scene" }</c> result (returned, not thrown,
|
||||
/// to match the CLI-215 spec), and nothing is started.
|
||||
/// </summary>
|
||||
internal static class BakeSceneGuard
|
||||
{
|
||||
/// <summary>The structured "no_scene" error payload returned by a trigger with no bakeable scene.</summary>
|
||||
internal static object NoSceneResult()
|
||||
{
|
||||
return new
|
||||
{
|
||||
code = "no_scene",
|
||||
message = "No open, saved scene to bake. Open a scene first (open_scene) and ensure it is saved to disk."
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when there is at least one open, loaded scene that has been saved to disk (a non-empty
|
||||
/// path). An untitled/never-saved scene has an empty <see cref="Scene.path"/> and cannot host a
|
||||
/// bake (its data has nowhere to live), so it does not count.
|
||||
/// </summary>
|
||||
internal static bool HasBakeableScene()
|
||||
{
|
||||
return OpenScenes().Any(s => s.isLoaded && !string.IsNullOrEmpty(s.path));
|
||||
}
|
||||
|
||||
/// <summary>Enumerate every open scene by index.</summary>
|
||||
internal static System.Collections.Generic.IEnumerable<Scene> OpenScenes()
|
||||
{
|
||||
for (int i = 0; i < SceneManager.sceneCount; i++)
|
||||
yield return SceneManager.GetSceneAt(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cf05be2261cb475c9cf3d7fccbf65ff5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,646 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
using Unity.Scripting.LifecycleManagement;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Baking
|
||||
{
|
||||
/// <summary>
|
||||
/// CLI-215 Group A — Lightmapping bake commands. Trigger an async lightmap bake of the open
|
||||
/// scene(s), poll its status, cancel it, clear baked data, and read/configure the active
|
||||
/// <see cref="LightingSettings"/>.
|
||||
///
|
||||
/// Why async (trigger-then-poll, mirroring <see cref="BuildCommand"/>): a full lightmap bake runs
|
||||
/// for seconds-to-minutes. <see cref="Lightmapping.BakeAsync"/> kicks the bake off and returns
|
||||
/// immediately; <c>bake_lighting</c> therefore returns <c>{ status: "baking", bakeId }</c> right
|
||||
/// away and the caller polls <c>lighting_bake_status</c> until it reports <c>completed</c>.
|
||||
///
|
||||
/// In-flight state is held in static fields and is domain-reload aware: a bake can survive (or
|
||||
/// resume across) a domain reload, so on reload <see cref="OnReload"/> reconciles our recorded
|
||||
/// state against the live <see cref="Lightmapping.isRunning"/> flag and a small status file under
|
||||
/// Temp/ (which persists across the reload, mirroring RecompileCommand). Completion is detected via
|
||||
/// the <see cref="Lightmapping.bakeCompleted"/> callback and, defensively, by polling
|
||||
/// <see cref="Lightmapping.isRunning"/> flipping false in <see cref="EditorApplication.update"/>.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
[NoAutoStaticsCleanup]
|
||||
#endif
|
||||
static class LightingBakeCommands
|
||||
{
|
||||
const string StatusFile = "Temp/pipeline_lighting_bake_status.json";
|
||||
|
||||
static readonly object s_Lock = new object();
|
||||
|
||||
// True between trigger and the completion callback / isRunning flipping false.
|
||||
static bool s_Tracking;
|
||||
static string s_BakeId;
|
||||
static DateTime s_StartedUtc;
|
||||
|
||||
static LightingBakeCommands()
|
||||
{
|
||||
// Reconcile any in-flight bake recorded before a domain reload, then keep watching.
|
||||
OnReload();
|
||||
Lightmapping.bakeCompleted += OnBakeCompleted;
|
||||
EditorApplication.update += OnUpdate;
|
||||
}
|
||||
|
||||
#region bake_lighting
|
||||
|
||||
[CliCommand("bake_lighting", "Trigger an async lightmap bake of the open scene(s) via Lightmapping.BakeAsync(). Returns immediately; poll lighting_bake_status until completed.", Tags = new[] { "baking/lighting" })]
|
||||
public static object BakeLighting(
|
||||
[CliArg("confirm", "Recommended (true): a bake overwrites existing lightmap data. Accepted for parity; not required.")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, validate there is an open bakeable scene and return the current lighting settings without baking.")] bool dryRun = false)
|
||||
{
|
||||
if (!BakeSceneGuard.HasBakeableScene())
|
||||
return BakeSceneGuard.NoSceneResult();
|
||||
|
||||
// Reconcile before the in-progress check so a stale flag from a finished/cancelled bake
|
||||
// doesn't wrongly report bake_in_progress.
|
||||
OnUpdate();
|
||||
|
||||
if (Lightmapping.isRunning)
|
||||
return new { code = "bake_in_progress", message = "A lighting bake is already running. Poll lighting_bake_status." };
|
||||
|
||||
if (dryRun)
|
||||
return new { status = "dry_run", wouldBake = true, settings = ReadLightingSettings() };
|
||||
|
||||
var bakeId = Guid.NewGuid().ToString("N");
|
||||
lock (s_Lock)
|
||||
{
|
||||
s_Tracking = true;
|
||||
s_BakeId = bakeId;
|
||||
s_StartedUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
WriteStatus(new LightingBakeStatus
|
||||
{
|
||||
Status = "baking",
|
||||
BakeId = bakeId,
|
||||
IsBaking = true,
|
||||
StartedUtcTicks = s_StartedUtc.Ticks
|
||||
});
|
||||
|
||||
// BakeAsync returns false if the bake could not be started (e.g. another job in flight).
|
||||
if (!Lightmapping.BakeAsync())
|
||||
{
|
||||
lock (s_Lock) { s_Tracking = false; }
|
||||
WriteStatus(new LightingBakeStatus { Status = "idle" });
|
||||
return new { code = "bake_in_progress", message = "Lightmapping.BakeAsync() refused to start (a bake may already be running)." };
|
||||
}
|
||||
|
||||
return new { status = "baking", bakeId };
|
||||
}
|
||||
|
||||
[CliCommand("lighting_bake_status", "Get the status of the last lighting bake: idle | baking | completed.", MainThreadRequired = false, Tags = new[] { "baking/lighting" })]
|
||||
public static string LightingBakeStatus()
|
||||
{
|
||||
if (File.Exists(StatusFile))
|
||||
return File.ReadAllText(StatusFile);
|
||||
return "{\"status\":\"idle\"}";
|
||||
}
|
||||
|
||||
[CliCommand("cancel_lighting_bake", "Cancel an in-progress lighting bake (Lightmapping.Cancel()).", Tags = new[] { "baking/lighting" })]
|
||||
public static object CancelLightingBake()
|
||||
{
|
||||
var wasRunning = Lightmapping.isRunning;
|
||||
if (wasRunning)
|
||||
Lightmapping.Cancel();
|
||||
|
||||
lock (s_Lock)
|
||||
{
|
||||
if (s_Tracking)
|
||||
{
|
||||
var ms = (int)Math.Max(0, (DateTime.UtcNow - s_StartedUtc).TotalMilliseconds);
|
||||
WriteStatus(new LightingBakeStatus
|
||||
{
|
||||
Status = "completed",
|
||||
Result = "cancelled",
|
||||
BakeId = s_BakeId,
|
||||
BakeTimeMs = ms
|
||||
});
|
||||
s_Tracking = false;
|
||||
}
|
||||
}
|
||||
|
||||
return new { cancelled = wasRunning };
|
||||
}
|
||||
|
||||
[CliCommand("clear_baked_lighting", "Clear baked lightmap data for the open scene(s). Destructive: requires confirm=true.", Tags = new[] { "baking/lighting" })]
|
||||
public static object ClearBakedLighting(
|
||||
[CliArg("confirm", "Must be true to actually clear (destructive, not undoable via Unity's Undo).")] bool confirm = false,
|
||||
[CliArg("include_disk_cache", "If true, also clear the GI disk cache (Lightmapping.ClearDiskCache()).")] bool includeDiskCache = false,
|
||||
[CliArg("dry_run", "If true, report what would be cleared without clearing.")] bool dryRun = false)
|
||||
{
|
||||
if (!confirm)
|
||||
throw new ArgumentException("Refusing to clear baked lighting. Pass confirm=true (destructive, not undoable via Unity's Undo).");
|
||||
|
||||
if (dryRun)
|
||||
return new { status = "dry_run", wouldClear = true, includeDiskCache };
|
||||
|
||||
Lightmapping.Clear();
|
||||
if (includeDiskCache)
|
||||
Lightmapping.ClearDiskCache();
|
||||
|
||||
return new { cleared = true, includeDiskCache };
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region settings
|
||||
|
||||
[CliCommand("get_lighting_settings", "Read the active LightingSettings (lightmapper, bounces, resolution, directional mode, AO, etc.).", Tags = new[] { "baking/lighting" })]
|
||||
public static LightingSettingsResult GetLightingSettings()
|
||||
{
|
||||
return ReadLightingSettings();
|
||||
}
|
||||
|
||||
[CliCommand("set_lighting_settings", "Apply a subset of lighting settings to the active LightingSettings. Returns { applied[], unknown[] }.", Tags = new[] { "baking/lighting" })]
|
||||
public static object SetLightingSettings(
|
||||
[CliArg("settings", "JSON object with a subset of lighting fields to set (same names/enums as get_lighting_settings).", Required = true)] JObject settings,
|
||||
[CliArg("dry_run", "If true, validate the keys and report applied/unknown without changing anything.")] bool dryRun = false)
|
||||
{
|
||||
if (settings == null)
|
||||
throw new ArgumentException("settings is required (a JSON object of lighting fields to set).");
|
||||
|
||||
// In dry_run we must not create or assign a LightingSettings asset (no writes). When no
|
||||
// settings exist yet, validate the requested keys against a transient throwaway instance
|
||||
// that is never assigned, so applied/unknown is still reported without persisting anything.
|
||||
var ls = ActiveLightingSettings(allowCreate: !dryRun, out var source);
|
||||
LightingSettings transient = null;
|
||||
if (ls == null)
|
||||
{
|
||||
transient = new LightingSettings { name = "PipelineLightingSettings (dry-run)" };
|
||||
ls = transient;
|
||||
source = "none";
|
||||
}
|
||||
|
||||
var applied = new System.Collections.Generic.List<string>();
|
||||
var unknown = new System.Collections.Generic.List<string>();
|
||||
|
||||
foreach (var prop in settings.Properties())
|
||||
{
|
||||
if (TryApply(ls, prop.Name, prop.Value, dryRun))
|
||||
applied.Add(prop.Name);
|
||||
else
|
||||
unknown.Add(prop.Name);
|
||||
}
|
||||
|
||||
if (!dryRun && applied.Count > 0)
|
||||
EditorUtility.SetDirty(ls);
|
||||
|
||||
// The transient dry-run instance is never assigned anywhere; destroy it so it doesn't leak.
|
||||
if (transient != null)
|
||||
UnityEngine.Object.DestroyImmediate(transient);
|
||||
|
||||
return new
|
||||
{
|
||||
applied = applied.ToArray(),
|
||||
unknown = unknown.ToArray(),
|
||||
settingsSource = source,
|
||||
dryRun
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region completion / reload bookkeeping
|
||||
|
||||
static void OnBakeCompleted()
|
||||
{
|
||||
lock (s_Lock)
|
||||
{
|
||||
if (!s_Tracking)
|
||||
return;
|
||||
Finish("succeeded");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defensive completion detector: if we are tracking a bake but Lightmapping reports it is no
|
||||
/// longer running and the completion callback didn't fire (e.g. a cancel, or a bake that ended
|
||||
/// during a frame we didn't get the callback), finalize the status here.
|
||||
/// </summary>
|
||||
static void OnUpdate()
|
||||
{
|
||||
lock (s_Lock)
|
||||
{
|
||||
if (s_Tracking && !Lightmapping.isRunning)
|
||||
Finish("succeeded");
|
||||
}
|
||||
}
|
||||
|
||||
// Must be called under s_Lock.
|
||||
static void Finish(string result)
|
||||
{
|
||||
var ms = (int)Math.Max(0, (DateTime.UtcNow - s_StartedUtc).TotalMilliseconds);
|
||||
var status = new LightingBakeStatus
|
||||
{
|
||||
Status = "completed",
|
||||
Result = result,
|
||||
BakeId = s_BakeId,
|
||||
BakeTimeMs = ms,
|
||||
Stats = result == "succeeded" ? CollectStats() : null
|
||||
};
|
||||
WriteStatus(status);
|
||||
s_Tracking = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reconcile recorded in-flight state after a domain reload. The status file under Temp/ survives
|
||||
/// the reload; if it said "baking" but Lightmapping is no longer running, the bake finished while
|
||||
/// the domain was being reloaded, so mark it completed. If it is still running, resume tracking.
|
||||
/// </summary>
|
||||
static void OnReload()
|
||||
{
|
||||
if (!File.Exists(StatusFile))
|
||||
return;
|
||||
|
||||
LightingBakeStatus prior;
|
||||
try
|
||||
{
|
||||
prior = JsonConvert.DeserializeObject<LightingBakeStatus>(File.ReadAllText(StatusFile));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (prior == null || prior.Status != "baking")
|
||||
return;
|
||||
|
||||
lock (s_Lock)
|
||||
{
|
||||
s_BakeId = prior.BakeId;
|
||||
s_StartedUtc = prior.StartedUtcTicks > 0 ? new DateTime(prior.StartedUtcTicks, DateTimeKind.Utc) : DateTime.UtcNow;
|
||||
|
||||
if (Lightmapping.isRunning)
|
||||
{
|
||||
s_Tracking = true; // resume; OnUpdate/OnBakeCompleted will finalize.
|
||||
}
|
||||
else
|
||||
{
|
||||
s_Tracking = true;
|
||||
Finish("succeeded"); // ended during the reload window.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region settings read/write helpers
|
||||
|
||||
/// <summary>
|
||||
/// The active <see cref="LightingSettings"/> for the open scene. When the scene has no explicit
|
||||
/// LightingSettings asset assigned, Unity falls back to a hidden per-scene default that
|
||||
/// <see cref="Lightmapping.GetLightingSettingsForScene"/> / <see cref="Lightmapping.lightingSettings"/>
|
||||
/// still exposes; we read/write that. <paramref name="source"/> records which we used.
|
||||
///
|
||||
/// When none is available and <paramref name="allowCreate"/> is true, a new settings asset is
|
||||
/// created and assigned so a real (non-dry_run) set has a target. When <paramref name="allowCreate"/>
|
||||
/// is false (dry_run), this returns <c>null</c> rather than creating/assigning anything, so the
|
||||
/// dry_run "no writes" contract holds.
|
||||
/// </summary>
|
||||
static LightingSettings ActiveLightingSettings(bool allowCreate, out string source)
|
||||
{
|
||||
// Lightmapping.lightingSettings returns the active scene's settings (assigned asset or the
|
||||
// scene's embedded/default settings). It throws if there is genuinely none; guard for that.
|
||||
try
|
||||
{
|
||||
var ls = Lightmapping.lightingSettings;
|
||||
if (ls != null)
|
||||
{
|
||||
source = AssetDatabase.Contains(ls) ? "asset" : "scene-default";
|
||||
return ls;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// fall through to (optionally) creating one
|
||||
}
|
||||
|
||||
if (!allowCreate)
|
||||
{
|
||||
source = "none";
|
||||
return null;
|
||||
}
|
||||
|
||||
// No settings available: create and assign one so set_lighting_settings has a target.
|
||||
var created = new LightingSettings { name = "PipelineLightingSettings" };
|
||||
Lightmapping.lightingSettings = created;
|
||||
source = "created";
|
||||
return created;
|
||||
}
|
||||
|
||||
static LightingSettingsResult ReadLightingSettings()
|
||||
{
|
||||
LightingSettings ls;
|
||||
try
|
||||
{
|
||||
ls = Lightmapping.lightingSettings;
|
||||
}
|
||||
catch
|
||||
{
|
||||
ls = null;
|
||||
}
|
||||
|
||||
if (ls == null)
|
||||
return new LightingSettingsResult { Available = false };
|
||||
|
||||
return new LightingSettingsResult
|
||||
{
|
||||
Available = true,
|
||||
Lightmapper = ls.lightmapper.ToString(),
|
||||
BakedGI = ls.bakedGI,
|
||||
RealtimeGI = ls.realtimeGI,
|
||||
DirectSampleCount = ls.directSampleCount,
|
||||
IndirectSampleCount = ls.indirectSampleCount,
|
||||
EnvironmentSampleCount = ls.environmentSampleCount,
|
||||
Bounces = ls.maxBounces,
|
||||
LightmapResolution = ls.lightmapResolution,
|
||||
LightmapPadding = ls.lightmapPadding,
|
||||
MaxLightmapSize = ls.lightmapMaxSize,
|
||||
LightmapCompression = ls.lightmapCompression.ToString(),
|
||||
// Unity 6000 serialises directionalityMode as a flags enum whose .ToString() can
|
||||
// produce compound strings (e.g. "Single, Dual") for values that span multiple bits.
|
||||
// Map to the two-value contract the spec guarantees: 0 → NonDirectional, else → Directional.
|
||||
DirectionalMode = (int)ls.directionalityMode == 0 ? "NonDirectional" : "Directional",
|
||||
Ao = ls.ao,
|
||||
AoMaxDistance = ls.aoMaxDistance,
|
||||
FilteringMode = ls.filteringMode.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply one settings key/value to the LightingSettings. Returns false for unrecognized keys so
|
||||
/// the caller can report them as <c>unknown[]</c>. Enum-valued fields accept their string names
|
||||
/// (case-insensitive). In dry-run mode we still validate (parse) the value but do not assign.
|
||||
/// </summary>
|
||||
static bool TryApply(LightingSettings ls, string key, JToken value, bool dryRun)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case "lightmapper":
|
||||
return SetEnum<LightingSettings.Lightmapper>(value, dryRun, v => ls.lightmapper = v);
|
||||
case "bakedGI":
|
||||
if (!dryRun) ls.bakedGI = value.Value<bool>();
|
||||
return true;
|
||||
case "realtimeGI":
|
||||
if (!dryRun) ls.realtimeGI = value.Value<bool>();
|
||||
return true;
|
||||
case "directSampleCount":
|
||||
if (!dryRun) ls.directSampleCount = value.Value<int>();
|
||||
return true;
|
||||
case "indirectSampleCount":
|
||||
if (!dryRun) ls.indirectSampleCount = value.Value<int>();
|
||||
return true;
|
||||
case "environmentSampleCount":
|
||||
if (!dryRun) ls.environmentSampleCount = value.Value<int>();
|
||||
return true;
|
||||
case "bounces":
|
||||
if (!dryRun) ls.maxBounces = value.Value<int>();
|
||||
return true;
|
||||
case "lightmapResolution":
|
||||
if (!dryRun) ls.lightmapResolution = value.Value<float>();
|
||||
return true;
|
||||
case "lightmapPadding":
|
||||
if (!dryRun) ls.lightmapPadding = value.Value<int>();
|
||||
return true;
|
||||
case "maxLightmapSize":
|
||||
if (!dryRun) ls.lightmapMaxSize = value.Value<int>();
|
||||
return true;
|
||||
case "lightmapCompression":
|
||||
return SetEnum<LightmapCompression>(value, dryRun, v => ls.lightmapCompression = v);
|
||||
case "directionalMode":
|
||||
// Accept the two spec names ("NonDirectional", "Directional") and also Unity's
|
||||
// internal name ("CombinedDirectional") for back-compat. Integer values pass through
|
||||
// as-is so callers can target any raw enum slot Unity exposes.
|
||||
if (value.Type == JTokenType.Integer)
|
||||
{
|
||||
if (!dryRun) ls.directionalityMode = (LightmapsMode)value.Value<int>();
|
||||
return true;
|
||||
}
|
||||
var modeStr = value.Value<string>() ?? string.Empty;
|
||||
if (modeStr.Equals("NonDirectional", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!dryRun) ls.directionalityMode = (LightmapsMode)0;
|
||||
return true;
|
||||
}
|
||||
if (modeStr.Equals("Directional", StringComparison.OrdinalIgnoreCase) ||
|
||||
modeStr.Equals("CombinedDirectional", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!dryRun) ls.directionalityMode = (LightmapsMode)1;
|
||||
return true;
|
||||
}
|
||||
throw new ArgumentException(
|
||||
$"Value '{modeStr}' is not valid for directionalMode. Valid: NonDirectional, Directional.");
|
||||
case "ao":
|
||||
if (!dryRun) ls.ao = value.Value<bool>();
|
||||
return true;
|
||||
case "aoMaxDistance":
|
||||
if (!dryRun) ls.aoMaxDistance = value.Value<float>();
|
||||
return true;
|
||||
case "filteringMode":
|
||||
return SetEnum<LightingSettings.FilterMode>(value, dryRun, v => ls.filteringMode = v);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool SetEnum<TEnum>(JToken value, bool dryRun, Action<TEnum> assign) where TEnum : struct
|
||||
{
|
||||
// Accept either the enum's string name (case-insensitive) or its integer value.
|
||||
if (value.Type == JTokenType.Integer)
|
||||
{
|
||||
var iv = value.Value<int>();
|
||||
if (!Enum.IsDefined(typeof(TEnum), iv))
|
||||
throw new ArgumentException($"Value '{iv}' is not valid for {typeof(TEnum).Name}.");
|
||||
if (!dryRun) assign((TEnum)Enum.ToObject(typeof(TEnum), iv));
|
||||
return true;
|
||||
}
|
||||
|
||||
var name = value.Value<string>();
|
||||
if (!Enum.TryParse<TEnum>(name, ignoreCase: true, out var parsed))
|
||||
throw new ArgumentException(
|
||||
$"Value '{name}' is not valid for {typeof(TEnum).Name}. Valid: {string.Join(", ", Enum.GetNames(typeof(TEnum)))}.");
|
||||
if (!dryRun) assign(parsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region stats
|
||||
|
||||
/// <summary>
|
||||
/// Collect post-bake lightmap statistics from <see cref="LightmapSettings.lightmaps"/>: the
|
||||
/// number of baked lightmaps, their total texture size in bytes (color maps), the atlas size,
|
||||
/// the active lightmap resolution, and the baked light-probe count.
|
||||
/// </summary>
|
||||
static LightingBakeStats CollectStats()
|
||||
{
|
||||
var stats = new LightingBakeStats();
|
||||
|
||||
var maps = LightmapSettings.lightmaps;
|
||||
if (maps != null)
|
||||
{
|
||||
stats.LightmapCount = maps.Length;
|
||||
long total = 0;
|
||||
int atlas = 0;
|
||||
foreach (var data in maps)
|
||||
{
|
||||
var tex = data?.lightmapColor;
|
||||
if (tex == null)
|
||||
continue;
|
||||
atlas = Mathf.Max(atlas, Mathf.Max(tex.width, tex.height));
|
||||
total += EstimateTextureBytes(tex);
|
||||
}
|
||||
stats.TotalLightmapSizeBytes = total;
|
||||
stats.AtlasSize = atlas;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
stats.LightmapResolution = Lightmapping.lightingSettings != null
|
||||
? Lightmapping.lightingSettings.lightmapResolution
|
||||
: 0f;
|
||||
}
|
||||
catch
|
||||
{
|
||||
stats.LightmapResolution = 0f;
|
||||
}
|
||||
|
||||
var probes = LightmapSettings.lightProbes;
|
||||
stats.BakedProbeCount = probes != null ? probes.count : 0;
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/// <summary>Rough byte estimate for a baked lightmap texture (4 bytes/pixel; good enough for a size signal).</summary>
|
||||
static long EstimateTextureBytes(Texture tex)
|
||||
{
|
||||
return (long)tex.width * tex.height * 4;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
static void WriteStatus(LightingBakeStatus status)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[LightingBake] Failed to write status file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Status/result payload for <c>bake_lighting</c> / <c>lighting_bake_status</c>.</summary>
|
||||
[Serializable]
|
||||
class LightingBakeStatus
|
||||
{
|
||||
[JsonProperty("status")]
|
||||
public string Status { get; set; }
|
||||
|
||||
[JsonProperty("bakeId")]
|
||||
public string BakeId { get; set; }
|
||||
|
||||
[JsonProperty("result", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Result { get; set; }
|
||||
|
||||
[JsonProperty("isBaking")]
|
||||
public bool IsBaking { get; set; }
|
||||
|
||||
[JsonProperty("bakeTimeMs")]
|
||||
public int BakeTimeMs { get; set; }
|
||||
|
||||
[JsonProperty("stats", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public LightingBakeStats Stats { get; set; }
|
||||
|
||||
// Internal bookkeeping so a bake can be reconciled across a domain reload. Not part of the
|
||||
// documented schema, but harmless to expose.
|
||||
[JsonProperty("startedUtcTicks")]
|
||||
public long StartedUtcTicks { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Post-bake lightmap statistics (CLI-215 acceptance: lightmapCount >= 1, bakeTimeMs > 0).</summary>
|
||||
[Serializable]
|
||||
class LightingBakeStats
|
||||
{
|
||||
[JsonProperty("lightmapCount")]
|
||||
public int LightmapCount { get; set; }
|
||||
|
||||
[JsonProperty("totalLightmapSizeBytes")]
|
||||
public long TotalLightmapSizeBytes { get; set; }
|
||||
|
||||
[JsonProperty("atlasSize")]
|
||||
public int AtlasSize { get; set; }
|
||||
|
||||
[JsonProperty("lightmapResolution")]
|
||||
public float LightmapResolution { get; set; }
|
||||
|
||||
[JsonProperty("bakedProbeCount")]
|
||||
public int BakedProbeCount { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Result of <c>get_lighting_settings</c> (and the dry-run payload of bake_lighting).</summary>
|
||||
[Serializable]
|
||||
class LightingSettingsResult
|
||||
{
|
||||
/// <summary>False when no LightingSettings could be read for the active scene.</summary>
|
||||
[JsonProperty("available")]
|
||||
public bool Available { get; set; }
|
||||
|
||||
[JsonProperty("lightmapper", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Lightmapper { get; set; }
|
||||
|
||||
[JsonProperty("bakedGI")]
|
||||
public bool BakedGI { get; set; }
|
||||
|
||||
[JsonProperty("realtimeGI")]
|
||||
public bool RealtimeGI { get; set; }
|
||||
|
||||
[JsonProperty("directSampleCount")]
|
||||
public int DirectSampleCount { get; set; }
|
||||
|
||||
[JsonProperty("indirectSampleCount")]
|
||||
public int IndirectSampleCount { get; set; }
|
||||
|
||||
[JsonProperty("environmentSampleCount")]
|
||||
public int EnvironmentSampleCount { get; set; }
|
||||
|
||||
[JsonProperty("bounces")]
|
||||
public int Bounces { get; set; }
|
||||
|
||||
[JsonProperty("lightmapResolution")]
|
||||
public float LightmapResolution { get; set; }
|
||||
|
||||
[JsonProperty("lightmapPadding")]
|
||||
public int LightmapPadding { get; set; }
|
||||
|
||||
[JsonProperty("maxLightmapSize")]
|
||||
public int MaxLightmapSize { get; set; }
|
||||
|
||||
[JsonProperty("lightmapCompression", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string LightmapCompression { get; set; }
|
||||
|
||||
[JsonProperty("directionalMode", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string DirectionalMode { get; set; }
|
||||
|
||||
[JsonProperty("ao")]
|
||||
public bool Ao { get; set; }
|
||||
|
||||
[JsonProperty("aoMaxDistance")]
|
||||
public float AoMaxDistance { get; set; }
|
||||
|
||||
[JsonProperty("filteringMode", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string FilteringMode { get; set; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dd7529c9d1f14ff98b9eb3e9a1e8ba5b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,430 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEditor.AI;
|
||||
using UnityEngine;
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
using Unity.Scripting.LifecycleManagement;
|
||||
#endif
|
||||
|
||||
// CLI-215 deliberately targets the built-in legacy UnityEditor.AI.NavMeshBuilder (see class doc). It is
|
||||
// [Obsolete] in Unity 6000.x (the modern path is the com.unity.ai.navigation package), but is the
|
||||
// intentional v1 baker here, so suppress CS0618 file-wide rather than migrate.
|
||||
#pragma warning disable CS0618
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Baking
|
||||
{
|
||||
/// <summary>
|
||||
/// CLI-215 Group B — NavMesh bake commands, targeting the <b>built-in legacy</b> baker
|
||||
/// <see cref="UnityEditor.AI.NavMeshBuilder"/> (always available; bakes a single scene NavMesh from
|
||||
/// the Navigation-static geometry of the open scene). Trigger an async build, poll its status,
|
||||
/// cancel it, clear the baked NavMesh, and read/configure the default agent's bake settings.
|
||||
///
|
||||
/// The newer AI Navigation package (<c>com.unity.ai.navigation</c>, <c>NavMeshSurface</c>
|
||||
/// components) is OUT for v1 — we do NOT add an asmdef reference to it. <c>bake_navmesh_surfaces</c>
|
||||
/// is a guarded stub that probes for the package by reflection and returns
|
||||
/// <c>{ code: "package_not_found" }</c> when it is absent (full support is a follow-up).
|
||||
///
|
||||
/// Async pattern mirrors <see cref="BuildCommand"/> / <see cref="LightingBakeCommands"/>: trigger
|
||||
/// returns immediately, completion is detected by <see cref="NavMeshBuilder.isRunning"/> flipping
|
||||
/// false (polled in <see cref="EditorApplication.update"/>), and in-flight state is held in static
|
||||
/// fields reconciled against a Temp/ status file across domain reloads.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
[NoAutoStaticsCleanup]
|
||||
#endif
|
||||
static class NavMeshBakeCommands
|
||||
{
|
||||
const string StatusFile = "Temp/pipeline_navmesh_bake_status.json";
|
||||
|
||||
// The serialized NavMesh bake-settings live on the Navigation window's settings object, keyed by
|
||||
// these property names (stable across 2019+ / Unity 6).
|
||||
const string PropAgentRadius = "m_BuildSettings.agentRadius";
|
||||
const string PropAgentHeight = "m_BuildSettings.agentHeight";
|
||||
const string PropAgentSlope = "m_BuildSettings.agentSlope";
|
||||
const string PropAgentClimb = "m_BuildSettings.agentClimb";
|
||||
const string PropMinRegionArea = "m_BuildSettings.minRegionArea";
|
||||
const string PropManualCellSize = "m_BuildSettings.manualCellSize";
|
||||
const string PropCellSize = "m_BuildSettings.cellSize";
|
||||
|
||||
static readonly object s_Lock = new object();
|
||||
static bool s_Tracking;
|
||||
static string s_BakeId;
|
||||
static DateTime s_StartedUtc;
|
||||
|
||||
static NavMeshBakeCommands()
|
||||
{
|
||||
OnReload();
|
||||
EditorApplication.update += OnUpdate;
|
||||
}
|
||||
|
||||
#region bake_navmesh
|
||||
|
||||
[CliCommand("bake_navmesh", "Trigger an async legacy NavMesh bake of the open scene(s) via UnityEditor.AI.NavMeshBuilder. Returns immediately; poll navmesh_bake_status until completed.", Tags = new[] { "baking/navmesh" })]
|
||||
public static object BakeNavMesh(
|
||||
[CliArg("confirm", "Accepted for parity (a bake overwrites the existing NavMesh); not required.")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, validate there is an open scene and return current NavMesh settings without baking.")] bool dryRun = false)
|
||||
{
|
||||
if (!BakeSceneGuard.HasBakeableScene())
|
||||
return BakeSceneGuard.NoSceneResult();
|
||||
|
||||
OnUpdate(); // reconcile a possibly-stale tracking flag first.
|
||||
|
||||
if (NavMeshBuilder.isRunning)
|
||||
return new { code = "bake_in_progress", message = "A NavMesh bake is already running. Poll navmesh_bake_status." };
|
||||
|
||||
if (dryRun)
|
||||
return new { status = "dry_run", wouldBake = true, settings = ReadNavMeshSettings() };
|
||||
|
||||
var bakeId = Guid.NewGuid().ToString("N");
|
||||
lock (s_Lock)
|
||||
{
|
||||
s_Tracking = true;
|
||||
s_BakeId = bakeId;
|
||||
s_StartedUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
WriteStatus(new NavMeshBakeStatus
|
||||
{
|
||||
Status = "baking",
|
||||
BakeId = bakeId,
|
||||
StartedUtcTicks = s_StartedUtc.Ticks
|
||||
});
|
||||
|
||||
NavMeshBuilder.BuildNavMeshAsync();
|
||||
return new { status = "baking", bakeId };
|
||||
}
|
||||
|
||||
[CliCommand("navmesh_bake_status", "Get the status of the last NavMesh bake: idle | baking | completed.", MainThreadRequired = false, Tags = new[] { "baking/navmesh" })]
|
||||
public static string NavMeshBakeStatus()
|
||||
{
|
||||
if (File.Exists(StatusFile))
|
||||
return File.ReadAllText(StatusFile);
|
||||
return "{\"status\":\"idle\"}";
|
||||
}
|
||||
|
||||
[CliCommand("cancel_navmesh_bake", "Cancel an in-progress NavMesh bake (NavMeshBuilder.Cancel()).", Tags = new[] { "baking/navmesh" })]
|
||||
public static object CancelNavMeshBake()
|
||||
{
|
||||
var wasRunning = NavMeshBuilder.isRunning;
|
||||
if (wasRunning)
|
||||
NavMeshBuilder.Cancel();
|
||||
|
||||
lock (s_Lock)
|
||||
{
|
||||
if (s_Tracking)
|
||||
{
|
||||
var ms = (int)Math.Max(0, (DateTime.UtcNow - s_StartedUtc).TotalMilliseconds);
|
||||
WriteStatus(new NavMeshBakeStatus
|
||||
{
|
||||
Status = "completed",
|
||||
Result = "cancelled",
|
||||
BakeId = s_BakeId,
|
||||
BakeTimeMs = ms
|
||||
});
|
||||
s_Tracking = false;
|
||||
}
|
||||
}
|
||||
|
||||
return new { cancelled = wasRunning };
|
||||
}
|
||||
|
||||
[CliCommand("clear_navmesh", "Clear the baked NavMesh for the open scene(s). Destructive: requires confirm=true.", Tags = new[] { "baking/navmesh" })]
|
||||
public static object ClearNavMesh(
|
||||
[CliArg("confirm", "Must be true to actually clear (destructive, not undoable via Unity's Undo).")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, report what would be cleared without clearing.")] bool dryRun = false)
|
||||
{
|
||||
if (!confirm)
|
||||
throw new ArgumentException("Refusing to clear the NavMesh. Pass confirm=true (destructive, not undoable via Unity's Undo).");
|
||||
|
||||
if (dryRun)
|
||||
return new { status = "dry_run", wouldClear = true };
|
||||
|
||||
NavMeshBuilder.ClearAllNavMeshes();
|
||||
return new { cleared = true };
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region settings
|
||||
|
||||
[CliCommand("get_navmesh_settings", "Read the default agent's legacy NavMesh bake settings (agentRadius/Height/Slope/Climb, minRegionArea, voxelSize).", Tags = new[] { "baking/navmesh" })]
|
||||
public static NavMeshSettingsResult GetNavMeshSettings()
|
||||
{
|
||||
return ReadNavMeshSettings();
|
||||
}
|
||||
|
||||
[CliCommand("set_navmesh_settings", "Apply a subset of legacy NavMesh bake settings to the default agent. Returns { applied[], unknown[] }.", Tags = new[] { "baking/navmesh" })]
|
||||
public static object SetNavMeshSettings(
|
||||
[CliArg("settings", "JSON object with a subset of NavMesh fields to set (same names as get_navmesh_settings).", Required = true)] JObject settings,
|
||||
[CliArg("dry_run", "If true, validate the keys and report applied/unknown without changing anything.")] bool dryRun = false)
|
||||
{
|
||||
if (settings == null)
|
||||
throw new ArgumentException("settings is required (a JSON object of NavMesh fields to set).");
|
||||
|
||||
// NavMeshBuilder.navMeshSettingsObject is a UnityEngine.Object (the Navigation window's
|
||||
// settings holder); wrap it in a SerializedObject to read/write the m_BuildSettings fields.
|
||||
var settingsObject = NavMeshBuilder.navMeshSettingsObject;
|
||||
if (settingsObject == null)
|
||||
return new { code = "settings_unavailable", message = "Could not access the legacy NavMesh settings object." };
|
||||
|
||||
var so = new SerializedObject(settingsObject);
|
||||
so.Update();
|
||||
|
||||
var applied = new List<string>();
|
||||
var unknown = new List<string>();
|
||||
|
||||
foreach (var prop in settings.Properties())
|
||||
{
|
||||
if (TryApply(so, prop.Name, prop.Value, dryRun))
|
||||
applied.Add(prop.Name);
|
||||
else
|
||||
unknown.Add(prop.Name);
|
||||
}
|
||||
|
||||
if (!dryRun && applied.Count > 0)
|
||||
so.ApplyModifiedPropertiesWithoutUndo();
|
||||
|
||||
return new { applied = applied.ToArray(), unknown = unknown.ToArray(), dryRun };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Guarded stub for the AI Navigation package (<c>com.unity.ai.navigation</c>) NavMeshSurface
|
||||
/// bake path. We never reference that assembly, so probe for the type by reflection; absent → the
|
||||
/// documented <c>package_not_found</c> result. Full multi-surface baking is a follow-up.
|
||||
/// </summary>
|
||||
[CliCommand("bake_navmesh_surfaces", "Bake NavMeshSurface components (AI Navigation package). v1 stub: returns package_not_found when the package is absent.", Tags = new[] { "baking/navmesh" })]
|
||||
public static object BakeNavMeshSurfaces()
|
||||
{
|
||||
var surfaceType = Type.GetType("Unity.AI.Navigation.NavMeshSurface, Unity.AI.Navigation")
|
||||
?? Type.GetType("UnityEngine.AI.NavMeshSurface, Unity.AI.Navigation");
|
||||
|
||||
if (surfaceType == null)
|
||||
return new
|
||||
{
|
||||
code = "package_not_found",
|
||||
message = "The AI Navigation package (com.unity.ai.navigation) is not installed. " +
|
||||
"NavMeshSurface-based baking is out of scope for v1; use bake_navmesh for the built-in baker."
|
||||
};
|
||||
|
||||
return new
|
||||
{
|
||||
code = "not_implemented",
|
||||
message = "AI Navigation package detected, but NavMeshSurface baking is not implemented in v1 (follow-up). Use bake_navmesh."
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region completion / reload bookkeeping
|
||||
|
||||
static void OnUpdate()
|
||||
{
|
||||
lock (s_Lock)
|
||||
{
|
||||
if (s_Tracking && !NavMeshBuilder.isRunning)
|
||||
Finish("succeeded");
|
||||
}
|
||||
}
|
||||
|
||||
// Must be called under s_Lock.
|
||||
static void Finish(string result)
|
||||
{
|
||||
var ms = (int)Math.Max(0, (DateTime.UtcNow - s_StartedUtc).TotalMilliseconds);
|
||||
WriteStatus(new NavMeshBakeStatus
|
||||
{
|
||||
Status = "completed",
|
||||
Result = result,
|
||||
BakeId = s_BakeId,
|
||||
BakeTimeMs = ms
|
||||
});
|
||||
s_Tracking = false;
|
||||
}
|
||||
|
||||
static void OnReload()
|
||||
{
|
||||
if (!File.Exists(StatusFile))
|
||||
return;
|
||||
|
||||
NavMeshBakeStatus prior;
|
||||
try
|
||||
{
|
||||
prior = JsonConvert.DeserializeObject<NavMeshBakeStatus>(File.ReadAllText(StatusFile));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (prior == null || prior.Status != "baking")
|
||||
return;
|
||||
|
||||
lock (s_Lock)
|
||||
{
|
||||
s_BakeId = prior.BakeId;
|
||||
s_StartedUtc = prior.StartedUtcTicks > 0 ? new DateTime(prior.StartedUtcTicks, DateTimeKind.Utc) : DateTime.UtcNow;
|
||||
s_Tracking = true;
|
||||
if (!NavMeshBuilder.isRunning)
|
||||
Finish("succeeded");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region settings read/write helpers
|
||||
|
||||
static NavMeshSettingsResult ReadNavMeshSettings()
|
||||
{
|
||||
var settingsObject = NavMeshBuilder.navMeshSettingsObject;
|
||||
if (settingsObject == null)
|
||||
return new NavMeshSettingsResult { Available = false };
|
||||
|
||||
var so = new SerializedObject(settingsObject);
|
||||
so.Update();
|
||||
var manual = FindBool(so, PropManualCellSize, false);
|
||||
return new NavMeshSettingsResult
|
||||
{
|
||||
Available = true,
|
||||
AgentRadius = FindFloat(so, PropAgentRadius, 0.5f),
|
||||
AgentHeight = FindFloat(so, PropAgentHeight, 2f),
|
||||
AgentSlope = FindFloat(so, PropAgentSlope, 45f),
|
||||
AgentClimb = FindFloat(so, PropAgentClimb, 0.4f),
|
||||
MinRegionArea = FindFloat(so, PropMinRegionArea, 2f),
|
||||
VoxelSize = FindFloat(so, PropCellSize, 0f),
|
||||
ManualVoxelSize = manual
|
||||
};
|
||||
}
|
||||
|
||||
static bool TryApply(SerializedObject so, string key, JToken value, bool dryRun)
|
||||
{
|
||||
switch (key)
|
||||
{
|
||||
case "agentRadius":
|
||||
return SetFloat(so, PropAgentRadius, value, dryRun);
|
||||
case "agentHeight":
|
||||
return SetFloat(so, PropAgentHeight, value, dryRun);
|
||||
case "agentSlope":
|
||||
return SetFloat(so, PropAgentSlope, value, dryRun);
|
||||
case "agentClimb":
|
||||
return SetFloat(so, PropAgentClimb, value, dryRun);
|
||||
case "minRegionArea":
|
||||
return SetFloat(so, PropMinRegionArea, value, dryRun);
|
||||
case "voxelSize":
|
||||
// Setting an explicit voxel size implies manual override on.
|
||||
{
|
||||
var ok = SetFloat(so, PropCellSize, value, dryRun);
|
||||
if (ok && !dryRun)
|
||||
{
|
||||
var manual = so.FindProperty(PropManualCellSize);
|
||||
if (manual != null) manual.boolValue = true;
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
case "manualVoxelSize":
|
||||
return SetBool(so, PropManualCellSize, value, dryRun);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool SetFloat(SerializedObject so, string propPath, JToken value, bool dryRun)
|
||||
{
|
||||
var prop = so.FindProperty(propPath);
|
||||
if (prop == null)
|
||||
return false;
|
||||
var f = value.Value<float>();
|
||||
if (!dryRun) prop.floatValue = f;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool SetBool(SerializedObject so, string propPath, JToken value, bool dryRun)
|
||||
{
|
||||
var prop = so.FindProperty(propPath);
|
||||
if (prop == null)
|
||||
return false;
|
||||
var b = value.Value<bool>();
|
||||
if (!dryRun) prop.boolValue = b;
|
||||
return true;
|
||||
}
|
||||
|
||||
static float FindFloat(SerializedObject so, string propPath, float fallback)
|
||||
{
|
||||
var prop = so.FindProperty(propPath);
|
||||
return prop != null ? prop.floatValue : fallback;
|
||||
}
|
||||
|
||||
static bool FindBool(SerializedObject so, string propPath, bool fallback)
|
||||
{
|
||||
var prop = so.FindProperty(propPath);
|
||||
return prop != null ? prop.boolValue : fallback;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
static void WriteStatus(NavMeshBakeStatus status)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[NavMeshBake] Failed to write status file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Status/result payload for <c>bake_navmesh</c> / <c>navmesh_bake_status</c>.</summary>
|
||||
[Serializable]
|
||||
class NavMeshBakeStatus
|
||||
{
|
||||
[JsonProperty("status")]
|
||||
public string Status { get; set; }
|
||||
|
||||
[JsonProperty("bakeId")]
|
||||
public string BakeId { get; set; }
|
||||
|
||||
[JsonProperty("result", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Result { get; set; }
|
||||
|
||||
[JsonProperty("bakeTimeMs")]
|
||||
public int BakeTimeMs { get; set; }
|
||||
|
||||
[JsonProperty("startedUtcTicks")]
|
||||
public long StartedUtcTicks { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Result of <c>get_navmesh_settings</c> (default-agent legacy bake settings).</summary>
|
||||
[Serializable]
|
||||
class NavMeshSettingsResult
|
||||
{
|
||||
[JsonProperty("available")]
|
||||
public bool Available { get; set; }
|
||||
|
||||
[JsonProperty("agentRadius")]
|
||||
public float AgentRadius { get; set; }
|
||||
|
||||
[JsonProperty("agentHeight")]
|
||||
public float AgentHeight { get; set; }
|
||||
|
||||
[JsonProperty("agentSlope")]
|
||||
public float AgentSlope { get; set; }
|
||||
|
||||
[JsonProperty("agentClimb")]
|
||||
public float AgentClimb { get; set; }
|
||||
|
||||
[JsonProperty("minRegionArea")]
|
||||
public float MinRegionArea { get; set; }
|
||||
|
||||
[JsonProperty("voxelSize")]
|
||||
public float VoxelSize { get; set; }
|
||||
|
||||
[JsonProperty("manualVoxelSize")]
|
||||
public bool ManualVoxelSize { get; set; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5173e39c97c44308ad4b80462eaf7982
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,271 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
using Unity.Scripting.LifecycleManagement;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Baking
|
||||
{
|
||||
/// <summary>
|
||||
/// CLI-215 Group C — Occlusion Culling bake commands. Trigger an async occlusion bake of the open
|
||||
/// scene(s) via <see cref="StaticOcclusionCulling.GenerateInBackground"/>, poll its status, cancel
|
||||
/// it, and clear the baked occlusion data.
|
||||
///
|
||||
/// Async pattern mirrors <see cref="BuildCommand"/> / <see cref="LightingBakeCommands"/>: trigger
|
||||
/// returns immediately; completion is detected by <see cref="StaticOcclusionCulling.isRunning"/>
|
||||
/// flipping false (polled in <see cref="EditorApplication.update"/>); in-flight state is held in
|
||||
/// static fields and reconciled against a Temp/ status file across domain reloads.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
[NoAutoStaticsCleanup]
|
||||
#endif
|
||||
static class OcclusionBakeCommands
|
||||
{
|
||||
const string StatusFile = "Temp/pipeline_occlusion_bake_status.json";
|
||||
|
||||
// Sentinel for "argument omitted" on the float bake parameters. float.NaN is not a
|
||||
// compile-time constant (so it can't be a default parameter value), but float.MinValue is, and
|
||||
// it is not a plausible real value for any of these positive bake parameters.
|
||||
const float Unset = float.MinValue;
|
||||
|
||||
static readonly object s_Lock = new object();
|
||||
static bool s_Tracking;
|
||||
static string s_BakeId;
|
||||
static DateTime s_StartedUtc;
|
||||
|
||||
static OcclusionBakeCommands()
|
||||
{
|
||||
OnReload();
|
||||
EditorApplication.update += OnUpdate;
|
||||
}
|
||||
|
||||
#region bake_occlusion_culling
|
||||
|
||||
[CliCommand("bake_occlusion_culling", "Trigger an async occlusion-culling bake of the open scene(s) via StaticOcclusionCulling.GenerateInBackground(). Returns immediately; poll occlusion_bake_status until completed.", Tags = new[] { "baking/occlusion" })]
|
||||
public static object BakeOcclusionCulling(
|
||||
[CliArg("smallest_occluder", "Smallest object that will occlude others (meters). Defaults to Unity's current value.")] float smallestOccluder = Unset,
|
||||
[CliArg("smallest_hole", "Smallest gap geometry can have that the view can see through (meters). Defaults to Unity's current value.")] float smallestHole = Unset,
|
||||
[CliArg("backface_threshold", "Backface threshold (1-100); lower trims more backfaces. Defaults to Unity's current value.")] float backfaceThreshold = Unset,
|
||||
[CliArg("confirm", "Accepted for parity (a bake overwrites existing occlusion data); not required.")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, validate there is an open scene and report the parameters that would be used without baking.")] bool dryRun = false)
|
||||
{
|
||||
if (!BakeSceneGuard.HasBakeableScene())
|
||||
return BakeSceneGuard.NoSceneResult();
|
||||
|
||||
OnUpdate(); // reconcile any stale tracking flag first.
|
||||
|
||||
if (StaticOcclusionCulling.isRunning)
|
||||
return new { code = "bake_in_progress", message = "An occlusion bake is already running. Poll occlusion_bake_status." };
|
||||
|
||||
// Validate explicitly provided values before resolving. An omitted arg is the Unset
|
||||
// sentinel (float.MinValue); NaN is never <= Unset, so a NaN arg is treated as provided
|
||||
// and rejected here. smallest_occluder / smallest_hole must be positive; backface_threshold
|
||||
// is documented as 1-100.
|
||||
if (!IsOmitted(smallestOccluder) && !(smallestOccluder > 0f))
|
||||
throw new ArgumentException($"smallest_occluder must be greater than 0 (got {smallestOccluder}).");
|
||||
if (!IsOmitted(smallestHole) && !(smallestHole > 0f))
|
||||
throw new ArgumentException($"smallest_hole must be greater than 0 (got {smallestHole}).");
|
||||
if (!IsOmitted(backfaceThreshold) && !(backfaceThreshold >= 1f && backfaceThreshold <= 100f))
|
||||
throw new ArgumentException($"backface_threshold must be between 1 and 100 (got {backfaceThreshold}).");
|
||||
|
||||
// Resolve effective parameters: an omitted arg (the Unset sentinel) keeps Unity's current value.
|
||||
var occluder = IsOmitted(smallestOccluder) ? StaticOcclusionCulling.smallestOccluder : smallestOccluder;
|
||||
var hole = IsOmitted(smallestHole) ? StaticOcclusionCulling.smallestHole : smallestHole;
|
||||
var backface = IsOmitted(backfaceThreshold) ? StaticOcclusionCulling.backfaceThreshold : backfaceThreshold;
|
||||
|
||||
if (dryRun)
|
||||
{
|
||||
return new
|
||||
{
|
||||
status = "dry_run",
|
||||
wouldBake = true,
|
||||
parameters = new { smallestOccluder = occluder, smallestHole = hole, backfaceThreshold = backface }
|
||||
};
|
||||
}
|
||||
|
||||
// Apply the (possibly overridden) bake parameters before generating.
|
||||
StaticOcclusionCulling.smallestOccluder = occluder;
|
||||
StaticOcclusionCulling.smallestHole = hole;
|
||||
StaticOcclusionCulling.backfaceThreshold = backface;
|
||||
|
||||
var bakeId = Guid.NewGuid().ToString("N");
|
||||
lock (s_Lock)
|
||||
{
|
||||
s_Tracking = true;
|
||||
s_BakeId = bakeId;
|
||||
s_StartedUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
WriteStatus(new OcclusionBakeStatus
|
||||
{
|
||||
Status = "baking",
|
||||
BakeId = bakeId,
|
||||
StartedUtcTicks = s_StartedUtc.Ticks
|
||||
});
|
||||
|
||||
StaticOcclusionCulling.GenerateInBackground();
|
||||
return new { status = "baking", bakeId };
|
||||
}
|
||||
|
||||
[CliCommand("occlusion_bake_status", "Get the status of the last occlusion bake: idle | baking | completed.", MainThreadRequired = false, Tags = new[] { "baking/occlusion" })]
|
||||
public static string OcclusionBakeStatus()
|
||||
{
|
||||
if (File.Exists(StatusFile))
|
||||
return File.ReadAllText(StatusFile);
|
||||
return "{\"status\":\"idle\"}";
|
||||
}
|
||||
|
||||
[CliCommand("cancel_occlusion_bake", "Cancel an in-progress occlusion bake (StaticOcclusionCulling.Cancel()).", Tags = new[] { "baking/occlusion" })]
|
||||
public static object CancelOcclusionBake()
|
||||
{
|
||||
var wasRunning = StaticOcclusionCulling.isRunning;
|
||||
if (wasRunning)
|
||||
StaticOcclusionCulling.Cancel();
|
||||
|
||||
lock (s_Lock)
|
||||
{
|
||||
if (s_Tracking)
|
||||
{
|
||||
var ms = (int)Math.Max(0, (DateTime.UtcNow - s_StartedUtc).TotalMilliseconds);
|
||||
WriteStatus(new OcclusionBakeStatus
|
||||
{
|
||||
Status = "completed",
|
||||
Result = "cancelled",
|
||||
BakeId = s_BakeId,
|
||||
BakeTimeMs = ms
|
||||
});
|
||||
s_Tracking = false;
|
||||
}
|
||||
}
|
||||
|
||||
return new { cancelled = wasRunning };
|
||||
}
|
||||
|
||||
[CliCommand("clear_occlusion_culling", "Clear baked occlusion-culling data for the open scene(s). Destructive: requires confirm=true.", Tags = new[] { "baking/occlusion" })]
|
||||
public static object ClearOcclusionCulling(
|
||||
[CliArg("confirm", "Must be true to actually clear (destructive, not undoable via Unity's Undo).")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, report what would be cleared without clearing.")] bool dryRun = false)
|
||||
{
|
||||
if (!confirm)
|
||||
throw new ArgumentException("Refusing to clear occlusion culling data. Pass confirm=true (destructive, not undoable via Unity's Undo).");
|
||||
|
||||
if (dryRun)
|
||||
return new { status = "dry_run", wouldClear = true };
|
||||
|
||||
StaticOcclusionCulling.Clear();
|
||||
return new { cleared = true };
|
||||
}
|
||||
|
||||
// True when the caller omitted a float bake arg (left it at the Unset sentinel). NaN is never
|
||||
// <= Unset, so NaN counts as provided and is caught by the range validation above.
|
||||
static bool IsOmitted(float value) => value <= Unset;
|
||||
|
||||
#endregion
|
||||
|
||||
#region completion / reload bookkeeping
|
||||
|
||||
static void OnUpdate()
|
||||
{
|
||||
lock (s_Lock)
|
||||
{
|
||||
if (s_Tracking && !StaticOcclusionCulling.isRunning)
|
||||
Finish("succeeded");
|
||||
}
|
||||
}
|
||||
|
||||
// Must be called under s_Lock.
|
||||
static void Finish(string result)
|
||||
{
|
||||
var ms = (int)Math.Max(0, (DateTime.UtcNow - s_StartedUtc).TotalMilliseconds);
|
||||
WriteStatus(new OcclusionBakeStatus
|
||||
{
|
||||
Status = "completed",
|
||||
Result = result,
|
||||
BakeId = s_BakeId,
|
||||
BakeTimeMs = ms,
|
||||
Stats = result == "succeeded"
|
||||
? new OcclusionBakeStats { UmbraDataSizeBytes = StaticOcclusionCulling.umbraDataSize }
|
||||
: null
|
||||
});
|
||||
s_Tracking = false;
|
||||
}
|
||||
|
||||
static void OnReload()
|
||||
{
|
||||
if (!File.Exists(StatusFile))
|
||||
return;
|
||||
|
||||
OcclusionBakeStatus prior;
|
||||
try
|
||||
{
|
||||
prior = JsonConvert.DeserializeObject<OcclusionBakeStatus>(File.ReadAllText(StatusFile));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (prior == null || prior.Status != "baking")
|
||||
return;
|
||||
|
||||
lock (s_Lock)
|
||||
{
|
||||
s_BakeId = prior.BakeId;
|
||||
s_StartedUtc = prior.StartedUtcTicks > 0 ? new DateTime(prior.StartedUtcTicks, DateTimeKind.Utc) : DateTime.UtcNow;
|
||||
s_Tracking = true;
|
||||
if (!StaticOcclusionCulling.isRunning)
|
||||
Finish("succeeded");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
static void WriteStatus(OcclusionBakeStatus status)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[OcclusionBake] Failed to write status file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Status/result payload for <c>bake_occlusion_culling</c> / <c>occlusion_bake_status</c>.</summary>
|
||||
[Serializable]
|
||||
class OcclusionBakeStatus
|
||||
{
|
||||
[JsonProperty("status")]
|
||||
public string Status { get; set; }
|
||||
|
||||
[JsonProperty("bakeId")]
|
||||
public string BakeId { get; set; }
|
||||
|
||||
[JsonProperty("result", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Result { get; set; }
|
||||
|
||||
[JsonProperty("bakeTimeMs")]
|
||||
public int BakeTimeMs { get; set; }
|
||||
|
||||
[JsonProperty("stats", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public OcclusionBakeStats Stats { get; set; }
|
||||
|
||||
[JsonProperty("startedUtcTicks")]
|
||||
public long StartedUtcTicks { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Post-bake occlusion statistics (umbra data size in bytes).</summary>
|
||||
[Serializable]
|
||||
class OcclusionBakeStats
|
||||
{
|
||||
[JsonProperty("umbraDataSizeBytes")]
|
||||
public long UmbraDataSizeBytes { get; set; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 00b06ed57bce4c0bb281527e016889dd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2b9ea99052584c228968a32f1c614458
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,660 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using UnityEditor;
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
using Unity.Scripting.LifecycleManagement;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Batch
|
||||
{
|
||||
/// <summary>
|
||||
/// The <c>batch</c> command (AUTHAPI-27): transactional multi-operation execution with cross-op
|
||||
/// result references, atomic Undo, and dry-run.
|
||||
///
|
||||
/// <para>WHY it exists: a "create → add components → wire fields" job is otherwise 10–90 sequential
|
||||
/// calls at ~0.8s each, and <c>eval</c> — the only alternative today — leaves partial state when it
|
||||
/// throws mid-way, which is unacceptable on user-owned scenes. <c>batch</c> collapses the round
|
||||
/// trips AND adds what eval structurally lacks: atomicity/rollback, dry-run validation, and
|
||||
/// cross-step references.</para>
|
||||
///
|
||||
/// <para>Scope guard (epic AUTHAPI-24 design principle): batch is transactional object CRUD with
|
||||
/// references. It deliberately has NO <c>invoke</c>/loop/conditional op types — that path reinvents
|
||||
/// eval inside JSON and collapses the auditability story. Arbitrary logic belongs in
|
||||
/// <c>run_script</c> (code as files on disk), not here.</para>
|
||||
///
|
||||
/// <para>Dispatch reuse: each sub-operation runs through
|
||||
/// <see cref="Unity.Pipeline.BasePipelineServer.DispatchCommandOnMainThread"/>, the exact parameter
|
||||
/// extraction / required-validation / reflection-invoke path <c>/api/exec</c> uses, so every per-op
|
||||
/// result is shaped identically to running that command standalone.</para>
|
||||
///
|
||||
/// <para>Atomicity: the whole batch runs inside one <see cref="AuthoringUndoScope"/>, so all ops
|
||||
/// (each of which registers its own Undo operations) collapse into a single Editor Undo step. When
|
||||
/// <c>transactional</c> (the default) and an op fails under <c>on_error=abort</c>, every applied op
|
||||
/// is reverted with <see cref="Undo.RevertAllDownToGroup(int)"/> — the scene returns to its
|
||||
/// pre-batch state and there is nothing left on the Undo stack. Rollback is only real for
|
||||
/// Undo-tracked mutations, which is why commands that mutate state OUTSIDE the Undo system
|
||||
/// (<see cref="m_NonRevertibleCommands"/>) are rejected in a transactional batch — see that field's
|
||||
/// doc for the v1 caveat.</para>
|
||||
///
|
||||
/// <para>Timing: the batch executes in a single main-thread turn (no frame-spanning), so the
|
||||
/// Editor UI is frozen for its duration. <c>time_budget_ms</c> is the cooperative bound on that
|
||||
/// freeze: the elapsed time is checked before each operation after the first, and when the budget
|
||||
/// is exhausted the remaining ops are skipped and a transactional batch rolls back. Long batches
|
||||
/// should be submitted as detached jobs (<c>"job": true</c>) — cancellation is checked between
|
||||
/// ops, and per-op progress is reported via <see cref="Unity.Pipeline.CliProgress"/>.</para>
|
||||
/// </summary>
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
[NoAutoStaticsCleanup]
|
||||
#endif
|
||||
static class BatchCommand
|
||||
{
|
||||
/// <summary>Hard cap on operations per batch (payload bound).</summary>
|
||||
private const int MaxOperations = 200;
|
||||
|
||||
/// <summary>Default cooperative time budget for one batch execution (see <see cref="Batch"/>).</summary>
|
||||
private const int DefaultTimeBudgetMs = 50_000;
|
||||
|
||||
/// <summary>Upper bound on <c>time_budget_ms</c> (1 hour).</summary>
|
||||
private const int MaxTimeBudgetMs = 3_600_000;
|
||||
|
||||
/// <summary>
|
||||
/// Per-op result size (serialized UTF-8 BYTES — what actually goes on the wire, not UTF-16
|
||||
/// chars, which undercount CJK/emoji-heavy payloads 3x) above which the result is replaced
|
||||
/// by a truncation marker so a large sub-result cannot bloat the batch envelope.
|
||||
/// </summary>
|
||||
private const int MaxPerOpResultBytes = 16 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate budget for ALL per-op results in one batch response (UTF-8 bytes). Bounds the
|
||||
/// whole envelope: 200 ops each just under the per-op cap would otherwise produce a ~3.2 MB
|
||||
/// reply. Once exhausted, later results are replaced by truncation markers (the ops still
|
||||
/// execute normally — only the echoed result payload is elided).
|
||||
/// </summary>
|
||||
internal const int MaxBatchResultBytes = 256 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Operation ids must look like identifiers: they may not be purely numeric (a numeric id
|
||||
/// would be ambiguous with a 0-based index in a <c>"$<id-or-index>"</c> reference) and
|
||||
/// may not contain <c>'.'</c> (the reference path separator).
|
||||
/// </summary>
|
||||
private static readonly Regex m_IdPattern = new Regex("^[A-Za-z_][A-Za-z0-9_-]*$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Commands excluded from batch (structured <c>not_batchable</c> error). These either escape
|
||||
/// the "object CRUD with references" scope (build/platform/package/play-mode/recompile), carry
|
||||
/// arbitrary code that belongs in <c>run_script</c> (<c>eval</c>/<c>eval_file</c>/<c>run_script</c>),
|
||||
/// can pop modal dialogs that wedge an unattended batch (<c>menu</c>), or would nest a batch
|
||||
/// inside a batch. (The ticket names <c>eval</c>; <c>eval_file</c> is its file-based twin and
|
||||
/// is excluded on the same grounds.)
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> m_ExcludedCommands = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"build", "switch_build_target",
|
||||
"editor_play", "editor_stop", "editor_pause",
|
||||
"recompile", "eval", "eval_file", "run_script", "menu", "batch",
|
||||
// A synchronous wait inside a batch cannot make progress: the batch holds the main
|
||||
// thread for its whole turn, so the editor state the condition watches cannot change —
|
||||
// the wait just burns its full timeout inside the batch. Use an async wait_for before
|
||||
// or after the batch instead. (wait_status/wait_cancel are quick reads and stay
|
||||
// batchable.)
|
||||
"wait_for"
|
||||
};
|
||||
|
||||
/// <summary>Excluded command families matched by name prefix (e.g. all <c>package_*</c>).</summary>
|
||||
private static readonly string[] m_ExcludedPrefixes = { "package_" };
|
||||
|
||||
/// <summary>
|
||||
/// Commands that CLEAR the Editor Undo stack, destroying the batch's ability to revert any
|
||||
/// prior operation. Always rejected inside a batch (even with <c>transactional=false</c> the
|
||||
/// single-Undo-step contract would silently break).
|
||||
///
|
||||
/// <para>V1 CAVEAT: like <see cref="m_NonRevertibleCommands"/> below, this is a curated list —
|
||||
/// any new command that clears or truncates the Undo stack must be added here, or it will
|
||||
/// silently break the revert-integrity contract. The durable fix is command-site metadata
|
||||
/// (<c>[CliCommand(Revertible = ...)]</c>).</para>
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> m_UndoStackWipingCommands = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
"open_scene", "create_scene"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Commands whose mutations live OUTSIDE the Editor Undo system — asset/file writes
|
||||
/// (AssetDatabase / File IO), scene saves, Build Settings, project settings — so
|
||||
/// <see cref="Undo.RevertAllDownToGroup(int)"/> cannot roll them back. In a transactional
|
||||
/// batch they are rejected (<c>not_batchable_transactional</c>): allowing them would make
|
||||
/// <c>reverted: true</c> a lie. With <c>transactional=false</c> they run, and their per-op
|
||||
/// result reports <c>revertible: false</c>.
|
||||
///
|
||||
/// <para>V1 CAVEAT: this is a curated list, assembled by auditing each command's
|
||||
/// implementation (AssetDatabase.SaveAssets / File writes / settings assets). The durable fix
|
||||
/// is a <c>[CliCommand(Revertible = ...)]</c> metadata flag declared at the command site so
|
||||
/// new commands can't silently fall through; until then, new non-Undo-tracked commands must
|
||||
/// be added here.</para>
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> m_NonRevertibleCommands = new HashSet<string>(StringComparer.Ordinal)
|
||||
{
|
||||
// Assets & files (AssetDatabase ops / direct file IO).
|
||||
"create_asset", "import_asset", "move_asset", "copy_asset", "rename_asset", "delete_asset",
|
||||
"create_folder", "set_import_settings", "write_text_file", "create_script",
|
||||
// Prefab ASSET writes (scene-side prefab ops — instantiate/unpack/revert overrides — are
|
||||
// Undo-tracked and stay batchable).
|
||||
"create_prefab", "create_prefab_variant", "apply_prefab_overrides", "save_prefab_contents",
|
||||
// Scene persistence and Build Settings scene list.
|
||||
"save_scene", "save_all", "add_scene_to_build", "remove_scene_from_build", "set_build_settings",
|
||||
// Project settings (their own descriptions say "Not undoable via Ctrl+Z").
|
||||
"set_audio_settings", "set_graphics_settings", "set_input_settings", "set_lighting_settings",
|
||||
"set_navmesh_settings", "set_physics_settings", "set_player_settings", "set_quality_settings",
|
||||
"set_tags_layers", "set_time_settings", "set_authoring_root",
|
||||
// Animation / Animator / Timeline commands persist their asset with AssetDatabase.SaveAssets.
|
||||
"create_animation_clip", "set_animation_curve", "remove_animation_curve",
|
||||
"create_animator_controller", "add_animator_layer", "add_animator_parameter",
|
||||
"add_animator_state", "add_animator_transition",
|
||||
"create_timeline", "add_timeline_track", "add_timeline_clip",
|
||||
// Persists the .mat asset with AssetDatabase.SaveAssetIfDirty (the in-memory change is
|
||||
// Undo-recorded, but the disk write is not reverted by Undo).
|
||||
"set_material_properties",
|
||||
// Baking writes/deletes baked data on disk (GI / NavMesh / occlusion).
|
||||
"bake_lighting", "clear_baked_lighting",
|
||||
"bake_navmesh", "bake_navmesh_surfaces", "clear_navmesh",
|
||||
"bake_occlusion_culling", "clear_occlusion_culling"
|
||||
};
|
||||
|
||||
[CliCommand("batch",
|
||||
"Run multiple registered commands in one transactional request. Later ops can reference earlier " +
|
||||
"results with \"$<id-or-index>.<jsonPath>\" (e.g. \"$0.instanceId\"; \"$$\" escapes a literal '$'). " +
|
||||
"transactional=true (default) groups every op into one Undo step and reverts all applied ops if any " +
|
||||
"op fails; commands that mutate outside the Undo system (asset/file/settings writes) are rejected " +
|
||||
"unless transactional=false. on_error=abort|continue (continue forces transactional=false); dry_run " +
|
||||
"validates without mutating; time_budget_ms (default 50000) bounds execution — when exceeded the " +
|
||||
"remaining ops are skipped and a transactional batch rolls back (submit long batches with " +
|
||||
"\"job\": true). Excludes build, switch_build_target, package_*, editor_play/stop/pause, recompile, " +
|
||||
"eval, eval_file, run_script, menu, open_scene/create_scene, async commands (run_tests/list_tests), " +
|
||||
"runtime-only commands, and nested batch.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "batch" })]
|
||||
public static BatchResult Batch(
|
||||
[CliArg("operations", "Ordered operations to run; each is { id?, command, params }. Max 200.", Required = true)] List<BatchOperationInput> operations = null,
|
||||
[CliArg("transactional", "Group all ops into one Undo step and revert every applied op if any op fails. Default true. Forced false when on_error=continue. Rejects commands whose mutations Undo cannot revert (asset/file/settings writes).")] bool transactional = true,
|
||||
[CliArg("on_error", "abort (default): stop at the first failing op. continue: run every op, collecting per-op errors (forces transactional=false).")] string onError = "abort",
|
||||
[CliArg("dry_run", "Validate command names, parameters, reference topology and excluded commands without mutating anything.")] bool dryRun = false,
|
||||
[CliArg("result_fields", "Optional per-op result projection: map of op id-or-index -> array of result field paths to keep (context economy).")] JObject resultFields = null,
|
||||
[CliArg("time_budget_ms", "Cooperative time budget for the whole batch in milliseconds (default 50000, max 3600000). Checked before each op after the first; when exhausted the remaining ops are skipped and a transactional batch rolls back. Use \"job\": true for long batches.")] int timeBudgetMs = DefaultTimeBudgetMs)
|
||||
{
|
||||
if (operations == null || operations.Count == 0)
|
||||
throw new ArgumentException("batch 'operations' must contain at least one operation.");
|
||||
if (operations.Count > MaxOperations)
|
||||
throw new ArgumentException(
|
||||
$"batch supports at most {MaxOperations} operations, but {operations.Count} were provided.");
|
||||
if (timeBudgetMs < 0 || timeBudgetMs > MaxTimeBudgetMs)
|
||||
throw new ArgumentException(
|
||||
$"batch 'time_budget_ms' must be between 0 and {MaxTimeBudgetMs}, got {timeBudgetMs}.");
|
||||
|
||||
onError = string.IsNullOrWhiteSpace(onError) ? "abort" : onError.Trim().ToLowerInvariant();
|
||||
if (onError != "abort" && onError != "continue")
|
||||
throw new ArgumentException($"batch 'on_error' must be 'abort' or 'continue', got '{onError}'.");
|
||||
|
||||
// "continue" collects per-op errors and applies the independent ops, so it cannot also
|
||||
// promise an all-or-nothing rollback — it forces transactional off (per the ticket).
|
||||
var effectiveTransactional = transactional && onError != "continue";
|
||||
|
||||
var idToIndex = BuildAndValidateIdIndex(operations);
|
||||
|
||||
if (dryRun)
|
||||
return DryRun(operations, idToIndex, effectiveTransactional, onError);
|
||||
|
||||
return Execute(operations, idToIndex, effectiveTransactional, onError, resultFields, timeBudgetMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate operation ids (well-formed and unique when supplied) and build the id → index map
|
||||
/// used to resolve <c>"$id.field"</c> references.
|
||||
/// </summary>
|
||||
private static Dictionary<string, int> BuildAndValidateIdIndex(List<BatchOperationInput> operations)
|
||||
{
|
||||
var idToIndex = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
for (int i = 0; i < operations.Count; i++)
|
||||
{
|
||||
var op = operations[i];
|
||||
if (op == null)
|
||||
throw new ArgumentException($"batch operation at index {i} is null.");
|
||||
if (op.Id == null)
|
||||
continue;
|
||||
if (!m_IdPattern.IsMatch(op.Id))
|
||||
throw new ArgumentException(
|
||||
$"batch operation id '{op.Id}' (index {i}) is invalid: ids must match " +
|
||||
"^[A-Za-z_][A-Za-z0-9_-]*$ (start with a letter or '_'; letters, digits, '_' or '-' " +
|
||||
"after). Purely numeric ids would be ambiguous with 0-based index selectors in " +
|
||||
"\"$<id-or-index>\" references, and '.' collides with the reference path separator.");
|
||||
if (idToIndex.ContainsKey(op.Id))
|
||||
throw new ArgumentException(
|
||||
$"batch operation id '{op.Id}' is used more than once; ids must be unique within a batch.");
|
||||
idToIndex[op.Id] = i;
|
||||
}
|
||||
return idToIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute the batch: run each op (resolving its references against earlier results) inside one
|
||||
/// Undo group, and on a transactional abort revert every applied op. Between ops, cooperative
|
||||
/// cancellation and the time budget are checked, and per-op progress is reported.
|
||||
/// </summary>
|
||||
private static BatchResult Execute(List<BatchOperationInput> operations, Dictionary<string, int> idToIndex,
|
||||
bool transactional, string onError, JObject resultFields, int timeBudgetMs)
|
||||
{
|
||||
var server = BasePipelineServer.CurrentServer;
|
||||
if (server == null)
|
||||
throw new InvalidOperationException(
|
||||
"batch must run inside a pipeline server request (no active server on this thread).");
|
||||
|
||||
var result = new BatchResult { Transactional = transactional, OnError = onError };
|
||||
// One name-indexed lookup per batch call instead of an O(commands) scan per op.
|
||||
var commandsByName = BuildCommandIndex();
|
||||
var resultsByIndex = new JToken[operations.Count];
|
||||
var stoppedAt = -1; // index of the op that failed under on_error=abort
|
||||
var skippedFrom = -1; // first op never attempted because of a batch-level abort (budget/cancel)
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
var remainingResultBytes = MaxBatchResultBytes;
|
||||
|
||||
using (var undoScope = new AuthoringUndoScope("batch"))
|
||||
{
|
||||
var batchGroup = Undo.GetCurrentGroup();
|
||||
result.UndoGroup = batchGroup;
|
||||
|
||||
for (int i = 0; i < operations.Count; i++)
|
||||
{
|
||||
// Cooperative cancellation (detached jobs: POST /api/job/cancel) — abort between
|
||||
// ops and let the transactional rollback below restore the pre-batch state.
|
||||
if (PipelineCancellation.IsCancellationRequested)
|
||||
{
|
||||
result.Error = "batch canceled: cooperative cancellation was requested; " +
|
||||
(transactional ? "applied operations were reverted and " : "") +
|
||||
"remaining operations were skipped.";
|
||||
skippedFrom = i;
|
||||
break;
|
||||
}
|
||||
|
||||
// Cooperative time budget: the batch holds the main thread for its whole duration,
|
||||
// so this is the bound on how long the Editor stays frozen. Op 0 always gets to
|
||||
// run; every later op requires remaining budget.
|
||||
if (i > 0 && stopwatch.ElapsedMilliseconds >= timeBudgetMs)
|
||||
{
|
||||
result.Error = $"batch time budget exceeded: {stopwatch.ElapsedMilliseconds}ms elapsed " +
|
||||
$"after {i} of {operations.Count} operations (time_budget_ms={timeBudgetMs}). " +
|
||||
(transactional ? "Applied operations were reverted and remaining operations were skipped. "
|
||||
: "Remaining operations were skipped. ") +
|
||||
"For long batches submit the request with \"job\": true and a larger time_budget_ms.";
|
||||
skippedFrom = i;
|
||||
break;
|
||||
}
|
||||
|
||||
var op = operations[i];
|
||||
CliProgress.Report("batch", $"op {i + 1}/{operations.Count}: {op.Command}",
|
||||
i, operations.Count, i / (double)operations.Count);
|
||||
|
||||
var opResult = new BatchOperationResult { Id = op.Id, Command = op.Command };
|
||||
result.Results.Add(opResult);
|
||||
|
||||
try
|
||||
{
|
||||
var command = ResolveBatchable(op.Command, transactional, commandsByName);
|
||||
opResult.Revertible = !m_NonRevertibleCommands.Contains(op.Command);
|
||||
var resolvedParams = BatchReferenceResolver.Resolve(
|
||||
op.Params, i, operations, idToIndex, resultsByIndex, result.Results);
|
||||
|
||||
// Dispatched WITHOUT re-checking the server's busy gate (GetBusyReason):
|
||||
// the batch itself was admitted through it in HandleExecRequest, the settle
|
||||
// gate is a one-way latch that never re-arms mid-session, and the batch
|
||||
// holds the main thread for its entire turn — the Editor cannot transition
|
||||
// into a busy state between two ops of the same batch.
|
||||
var raw = server.DispatchCommandOnMainThread(command, resolvedParams);
|
||||
|
||||
// Capture the result as a token for later references. A serialization failure
|
||||
// here must NOT fail an op that already ran — leave the slot null (a later
|
||||
// reference into it will error clearly) and still report success.
|
||||
try
|
||||
{
|
||||
resultsByIndex[i] = raw == null ? JValue.CreateNull() : JToken.FromObject(raw);
|
||||
}
|
||||
catch
|
||||
{
|
||||
resultsByIndex[i] = null;
|
||||
}
|
||||
|
||||
opResult.Success = true;
|
||||
opResult.Result = ProjectResult(raw, resultsByIndex[i], op, i, resultFields, opResult, ref remainingResultBytes);
|
||||
result.Applied++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
opResult.Success = false;
|
||||
opResult.Error = ex.Message;
|
||||
|
||||
if (onError == "abort")
|
||||
{
|
||||
stoppedAt = i;
|
||||
break;
|
||||
}
|
||||
// on_error=continue: keep going with the remaining independent ops.
|
||||
}
|
||||
}
|
||||
|
||||
if ((stoppedAt >= 0 || skippedFrom >= 0) && transactional)
|
||||
{
|
||||
// Revert every mutation registered since the batch started — one atomic rollback
|
||||
// to the pre-batch state (nothing is left on the Undo stack). Deliberately NOT
|
||||
// conditioned on Applied > 0: a FIRST op that partially mutates and then throws
|
||||
// has registered exactly the Undo operations this revert must unwind, and
|
||||
// reverting an empty group is harmless.
|
||||
Undo.RevertAllDownToGroup(batchGroup);
|
||||
result.Reverted = true;
|
||||
// The revert discarded batchGroup from the Undo stack; the scope must not
|
||||
// collapse a group id that no longer refers to this batch's operations.
|
||||
undoScope.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
// Record the operations that were never attempted because the batch aborted (op failure
|
||||
// under on_error=abort, cancellation, or an exhausted time budget), so the caller can see
|
||||
// exactly where it stopped.
|
||||
var firstSkipped = skippedFrom >= 0 ? skippedFrom
|
||||
: stoppedAt >= 0 ? stoppedAt + 1
|
||||
: operations.Count;
|
||||
for (int j = firstSkipped; j < operations.Count; j++)
|
||||
{
|
||||
result.Results.Add(new BatchOperationResult
|
||||
{
|
||||
Id = operations[j].Id,
|
||||
Command = operations[j].Command,
|
||||
Success = false,
|
||||
Skipped = true
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate every operation without executing any of it: command names, that no excluded /
|
||||
/// non-batchable command is used (including the transactional non-revertible rejection),
|
||||
/// unknown/missing parameters, and reference topology (unknown/forward refs). Mutates nothing.
|
||||
/// </summary>
|
||||
private static BatchResult DryRun(List<BatchOperationInput> operations, Dictionary<string, int> idToIndex,
|
||||
bool transactional, string onError)
|
||||
{
|
||||
var result = new BatchResult
|
||||
{
|
||||
DryRun = true,
|
||||
Valid = true,
|
||||
Transactional = transactional,
|
||||
OnError = onError
|
||||
};
|
||||
var commandsByName = BuildCommandIndex();
|
||||
|
||||
for (int i = 0; i < operations.Count; i++)
|
||||
{
|
||||
var op = operations[i];
|
||||
var opResult = new BatchOperationResult { Id = op.Id, Command = op.Command };
|
||||
result.Results.Add(opResult);
|
||||
|
||||
try
|
||||
{
|
||||
var command = ResolveBatchable(op.Command, transactional, commandsByName);
|
||||
opResult.Revertible = !m_NonRevertibleCommands.Contains(op.Command);
|
||||
ValidateKnownParameters(command, op.Params);
|
||||
ValidateRequiredParameters(command, op.Params);
|
||||
BatchReferenceResolver.Validate(op.Params, i, operations, idToIndex);
|
||||
opResult.Success = true; // would run
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
opResult.Success = false;
|
||||
opResult.Error = ex.Message;
|
||||
result.Valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a command name to its <see cref="CommandInfo"/>, rejecting non-batchable commands:
|
||||
/// the excluded list (<c>not_batchable</c>), Undo-stack-wiping scene loads, runtime-only
|
||||
/// commands, async (<c>Task</c>-returning) commands — which are completed by
|
||||
/// <c>EditorApplication.update</c> callbacks that can never fire while the batch holds the
|
||||
/// main thread, so awaiting them inline would freeze the Editor permanently — and, when
|
||||
/// <paramref name="transactional"/>, commands whose mutations Undo cannot revert
|
||||
/// (<c>not_batchable_transactional</c>). Unknown commands get an <c>unknown command</c> error.
|
||||
/// Uses the registry directly (no server-side logging) so dry-run validation of an
|
||||
/// intentionally bad name stays quiet.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Name-indexed snapshot of the registered commands, built once per batch call (the registry
|
||||
/// list itself is cached; this avoids an O(commands) scan per op). NOTE: this deliberately
|
||||
/// does not reuse <c>BasePipelineServer.ResolveCommand</c> — that helper serves the exec
|
||||
/// request path (logging, envelope error shape); unifying the two belongs to a follow-up so
|
||||
/// the server's hot path is not reshaped from this command.
|
||||
/// </summary>
|
||||
private static Dictionary<string, CommandInfo> BuildCommandIndex()
|
||||
{
|
||||
var byName = new Dictionary<string, CommandInfo>(StringComparer.Ordinal);
|
||||
foreach (var c in CommandRegistry.DiscoverCommands())
|
||||
if (!byName.ContainsKey(c.Name))
|
||||
byName.Add(c.Name, c);
|
||||
return byName;
|
||||
}
|
||||
|
||||
private static CommandInfo ResolveBatchable(string name, bool transactional, IReadOnlyDictionary<string, CommandInfo> byName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new ArgumentException("operation 'command' is required.");
|
||||
|
||||
if (IsExcluded(name))
|
||||
throw new ArgumentException(
|
||||
$"Command '{name}' is not batchable (not_batchable): commands that build, switch platform, " +
|
||||
"manage packages, control play mode, recompile, evaluate code, execute Editor menu items " +
|
||||
"(which can open modal dialogs and wedge an unattended batch), or nest a batch are excluded.");
|
||||
|
||||
if (m_UndoStackWipingCommands.Contains(name))
|
||||
throw new ArgumentException(
|
||||
$"Command '{name}' is not batchable (not_batchable): it clears the Editor Undo stack, which " +
|
||||
"would make every prior batch operation unrevertible. Call it standalone instead.");
|
||||
|
||||
if (!byName.TryGetValue(name, out var command))
|
||||
throw new ArgumentException($"No command named '{name}' is available (unknown command).");
|
||||
|
||||
if (command.RuntimeOnly)
|
||||
throw new ArgumentException(
|
||||
$"Command '{name}' is not batchable (not_batchable): runtime-only command (Player server " +
|
||||
"surface); it cannot execute on this Editor server.");
|
||||
|
||||
if (typeof(Task).IsAssignableFrom(command.Method.ReturnType))
|
||||
throw new ArgumentException(
|
||||
$"Command '{name}' is not batchable (not_batchable): async commands cannot run inside a " +
|
||||
"synchronous batch. They complete via EditorApplication.update callbacks, which cannot fire " +
|
||||
"while the batch holds the main thread — the batch would deadlock the Editor. Call it " +
|
||||
"standalone instead.");
|
||||
|
||||
if (transactional && m_NonRevertibleCommands.Contains(name))
|
||||
throw new ArgumentException(
|
||||
$"Command '{name}' is not batchable in a transactional batch (not_batchable_transactional): " +
|
||||
"it mutates state outside the Editor Undo system (asset/file/settings writes), so a rollback " +
|
||||
"could not revert it. Run the batch with transactional:false, or call the command standalone.");
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static bool IsExcluded(string name)
|
||||
{
|
||||
if (m_ExcludedCommands.Contains(name))
|
||||
return true;
|
||||
foreach (var prefix in m_ExcludedPrefixes)
|
||||
{
|
||||
if (name.StartsWith(prefix, StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Reject parameter keys that are not parameters of the command (dry-run check).</summary>
|
||||
private static void ValidateKnownParameters(CommandInfo command, JObject parameters)
|
||||
{
|
||||
if (parameters == null)
|
||||
return;
|
||||
var known = new HashSet<string>(command.Parameters.Select(p => p.Name), StringComparer.Ordinal);
|
||||
foreach (var prop in parameters.Properties())
|
||||
{
|
||||
if (!known.Contains(prop.Name))
|
||||
throw new ArgumentException(
|
||||
$"Command '{command.Name}' has no parameter '{prop.Name}' (unknown parameter). " +
|
||||
$"Known parameters: [{string.Join(", ", known)}].");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensure every required parameter is present (dry-run check). A reference string counts as
|
||||
/// present; an explicit JSON null does not.
|
||||
/// </summary>
|
||||
private static void ValidateRequiredParameters(CommandInfo command, JObject parameters)
|
||||
{
|
||||
foreach (var p in command.Parameters)
|
||||
{
|
||||
if (!p.Required)
|
||||
continue;
|
||||
var token = parameters?[p.Name];
|
||||
if (token == null || token.Type == JTokenType.Null)
|
||||
throw new ArgumentException(
|
||||
$"Command '{command.Name}' is missing required parameter '{p.Name}'.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shape an op's result: apply the optional <c>result_fields</c> projection, then replace an
|
||||
/// oversized result with a truncation marker. With neither in play the raw result object is
|
||||
/// returned unchanged, so it serializes byte-identically to the standalone command.
|
||||
/// </summary>
|
||||
internal static object ProjectResult(object raw, JToken rawToken, BatchOperationInput op, int index,
|
||||
JObject resultFields, BatchOperationResult opResult, ref int remainingResultBytes)
|
||||
{
|
||||
object projected = raw;
|
||||
|
||||
var fields = LookupResultFields(resultFields, op, index);
|
||||
if (fields != null && rawToken != null && rawToken.Type != JTokenType.Null)
|
||||
{
|
||||
var projection = new JObject();
|
||||
List<string> invalidPaths = null;
|
||||
foreach (var path in fields)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
continue;
|
||||
JToken value;
|
||||
try
|
||||
{
|
||||
value = rawToken.SelectToken(path);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A malformed JSONPath must NEVER throw here: by this point the op has
|
||||
// already run and mutated state, and the outer catch would flip its
|
||||
// Success to false — triggering a rollback of a succeeded op under
|
||||
// on_error=abort, and poisoning later $refs into a perfectly valid
|
||||
// captured result under on_error=continue. Report the bad path in the
|
||||
// projection instead.
|
||||
(invalidPaths ?? (invalidPaths = new List<string>())).Add(path);
|
||||
continue;
|
||||
}
|
||||
if (value != null)
|
||||
projection[path] = value;
|
||||
}
|
||||
if (invalidPaths != null)
|
||||
projection["invalidResultFields"] = new JArray(invalidPaths);
|
||||
projected = projection;
|
||||
}
|
||||
|
||||
string json;
|
||||
try
|
||||
{
|
||||
// Measure with the same formatting the wire envelope uses: since the lean envelope
|
||||
// (AUTHAPI-21) /api/exec serializes compact (Formatting.None) in every mode, so the
|
||||
// measurement matches what actually goes on the wire.
|
||||
json = JsonConvert.SerializeObject(projected, Formatting.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// A result Newtonsoft cannot serialize would fail (or bloat) the whole batch envelope
|
||||
// later. Replace it with a structured marker — and still flag resultTruncated — rather
|
||||
// than skipping truncation for exactly the results we know nothing about.
|
||||
opResult.ResultTruncated = true;
|
||||
return new JObject
|
||||
{
|
||||
["resultTruncated"] = true,
|
||||
["error"] = $"op result could not be serialized: {ex.Message}"
|
||||
};
|
||||
}
|
||||
|
||||
var bytes = System.Text.Encoding.UTF8.GetByteCount(json);
|
||||
if (bytes > MaxPerOpResultBytes)
|
||||
{
|
||||
opResult.ResultTruncated = true;
|
||||
return new JObject
|
||||
{
|
||||
["resultTruncated"] = true,
|
||||
["length"] = bytes,
|
||||
["preview"] = json.Substring(0, Math.Min(json.Length, 512))
|
||||
};
|
||||
}
|
||||
|
||||
if (bytes > remainingResultBytes)
|
||||
{
|
||||
// Aggregate budget exhausted: the op executed normally, only its echoed result is
|
||||
// elided so the whole batch reply stays bounded ($refs still resolve — they read
|
||||
// the captured token, not this projection).
|
||||
opResult.ResultTruncated = true;
|
||||
return new JObject
|
||||
{
|
||||
["resultTruncated"] = true,
|
||||
["length"] = bytes,
|
||||
["error"] = $"batch aggregate result budget ({MaxBatchResultBytes} bytes) exhausted; " +
|
||||
"the operation ran and later references to it still resolve, only this echoed result was elided. " +
|
||||
"Use result_fields to project results down, or split the batch.",
|
||||
["preview"] = json.Substring(0, Math.Min(json.Length, 512))
|
||||
};
|
||||
}
|
||||
remainingResultBytes -= bytes;
|
||||
|
||||
return projected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Look up the projection field list for an op in <c>result_fields</c> (keyed by op id, then
|
||||
/// by index). Returns null when no projection is configured for the op.
|
||||
/// </summary>
|
||||
private static string[] LookupResultFields(JObject resultFields, BatchOperationInput op, int index)
|
||||
{
|
||||
if (resultFields == null)
|
||||
return null;
|
||||
|
||||
JToken entry = null;
|
||||
if (!string.IsNullOrEmpty(op.Id))
|
||||
entry = resultFields[op.Id];
|
||||
if (entry == null)
|
||||
entry = resultFields[index.ToString()];
|
||||
if (entry == null)
|
||||
return null;
|
||||
|
||||
if (entry.Type == JTokenType.Array)
|
||||
return entry.ToObject<string[]>();
|
||||
if (entry.Type == JTokenType.String)
|
||||
return new[] { (string)entry };
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c33c9efe866346aab0db0dc6fad780a5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,43 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Batch
|
||||
{
|
||||
/// <summary>
|
||||
/// A single operation in a <c>batch</c> request (AUTHAPI-27): the name of a registered command
|
||||
/// plus the parameters to invoke it with, and an optional <see cref="Id"/> so later operations
|
||||
/// can reference this one's result.
|
||||
///
|
||||
/// String values anywhere in <see cref="Params"/> may reference an earlier operation's result
|
||||
/// with <c>"$<id-or-index>.<jsonPath>"</c> (e.g. <c>"$0.instanceId"</c> or
|
||||
/// <c>"$createHead.components[0].instanceId"</c>); <c>"$$"</c> escapes a literal <c>'$'</c>.
|
||||
/// References are backward-only. Implements <see cref="IStructuredCommandInput"/> so the batch
|
||||
/// command advertises a nested object schema (via <c>GET /api/commands</c>) rather than a string.
|
||||
/// </summary>
|
||||
sealed class BatchOperationInput : IStructuredCommandInput
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional operation id used to reference this operation's result from a later op
|
||||
/// (<c>"$<id>.<field>"</c>). Must be unique within the batch when supplied, and
|
||||
/// must match <c>^[A-Za-z_][A-Za-z0-9_-]*$</c> — purely numeric ids would be ambiguous with
|
||||
/// 0-based index selectors, and <c>'.'</c> collides with the reference path separator.
|
||||
/// </summary>
|
||||
[CliArg("id", "Optional id for referencing this op's result from a later op (\"$<id>.<field>\"). Unique within the batch; must match ^[A-Za-z_][A-Za-z0-9_-]*$ (not purely numeric, no dots).")]
|
||||
[JsonProperty("id")]
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>Name of the registered command to run (any command except the excluded list).</summary>
|
||||
[CliArg("command", "Name of the registered command to run.", Required = true)]
|
||||
[JsonProperty("command")]
|
||||
public string Command { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Parameters for the command, same shape as <c>/api/exec</c> <c>parameters</c>. Any string
|
||||
/// value may reference a prior op's result (<c>"$<id-or-index>.<jsonPath>"</c>).
|
||||
/// </summary>
|
||||
[CliArg("params", "Parameters for the command. String values may reference a prior op's result: \"$<id-or-index>.<jsonPath>\".")]
|
||||
[JsonProperty("params")]
|
||||
public JObject Params { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8f7946d807d348c0a98e13ba7d18f714
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Batch
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves cross-operation result references in a batch operation's parameters (AUTHAPI-27).
|
||||
///
|
||||
/// A string value that begins with a single <c>'$'</c> is a reference of the form
|
||||
/// <c>"$<selector>"</c> or <c>"$<selector>.<jsonPath>"</c>, where
|
||||
/// <c><selector></c> names an earlier operation by its <c>id</c> or its 0-based index and
|
||||
/// <c><jsonPath></c> is a Newtonsoft SelectToken path into that op's result
|
||||
/// (e.g. <c>"$0.instanceId"</c>, <c>"$createHead.components[0].instanceId"</c>). A leading
|
||||
/// <c>"$$"</c> escapes a literal <c>'$'</c>. References are backward-only: a selector must resolve
|
||||
/// to an operation strictly before the current one.
|
||||
///
|
||||
/// References are resolved as WHOLE values (the entire string must be the reference), and the
|
||||
/// substituted JSON token preserves its type — so a numeric <c>instanceId</c> stays a number and
|
||||
/// deserializes into the target parameter (e.g. an <c>ObjectRef</c>) exactly as a literal would.
|
||||
/// The walk recurses through nested objects and arrays, so a reference can appear anywhere in the
|
||||
/// parameter tree.
|
||||
/// </summary>
|
||||
internal static class BatchReferenceResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Validate the reference topology of <paramref name="parameters"/> without substituting any
|
||||
/// values (used by dry run). Throws <see cref="ArgumentException"/> on an unknown selector,
|
||||
/// a forward/self reference, or a malformed reference.
|
||||
/// </summary>
|
||||
public static void Validate(JToken parameters, int currentIndex,
|
||||
IReadOnlyList<BatchOperationInput> operations, IReadOnlyDictionary<string, int> idToIndex)
|
||||
{
|
||||
if (parameters == null)
|
||||
return;
|
||||
// No DeepClone: in validate mode (null resultsByIndex) TransformString always returns the
|
||||
// original token, so Walk never reassigns a child — the walk is read-only.
|
||||
Walk(parameters, currentIndex, operations, idToIndex, null, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return a copy of <paramref name="parameters"/> with every reference replaced by the
|
||||
/// referenced value from <paramref name="resultsByIndex"/>. Throws
|
||||
/// <see cref="ArgumentException"/> on an unknown selector, a forward/self reference, a
|
||||
/// reference whose path does not resolve, or a reference into an operation that failed, was
|
||||
/// skipped, or returned no result (per-op outcomes come from <paramref name="outcomes"/>).
|
||||
/// The input token is not mutated.
|
||||
/// </summary>
|
||||
public static JObject Resolve(JObject parameters, int currentIndex,
|
||||
IReadOnlyList<BatchOperationInput> operations, IReadOnlyDictionary<string, int> idToIndex,
|
||||
JToken[] resultsByIndex, IReadOnlyList<BatchOperationResult> outcomes)
|
||||
{
|
||||
if (parameters == null)
|
||||
return null;
|
||||
var clone = (JObject)parameters.DeepClone();
|
||||
return (JObject)Walk(clone, currentIndex, operations, idToIndex, resultsByIndex, outcomes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively transform a token tree. <paramref name="resultsByIndex"/> is null in validate
|
||||
/// mode (topology check only) and non-null in resolve mode (value substitution).
|
||||
/// </summary>
|
||||
private static JToken Walk(JToken node, int currentIndex,
|
||||
IReadOnlyList<BatchOperationInput> operations, IReadOnlyDictionary<string, int> idToIndex,
|
||||
JToken[] resultsByIndex, IReadOnlyList<BatchOperationResult> outcomes)
|
||||
{
|
||||
switch (node.Type)
|
||||
{
|
||||
case JTokenType.Object:
|
||||
foreach (var prop in ((JObject)node).Properties().ToList())
|
||||
{
|
||||
var replaced = Walk(prop.Value, currentIndex, operations, idToIndex, resultsByIndex, outcomes);
|
||||
if (!ReferenceEquals(replaced, prop.Value))
|
||||
prop.Value = replaced;
|
||||
}
|
||||
return node;
|
||||
case JTokenType.Array:
|
||||
var arr = (JArray)node;
|
||||
for (int i = 0; i < arr.Count; i++)
|
||||
{
|
||||
var child = arr[i];
|
||||
var replaced = Walk(child, currentIndex, operations, idToIndex, resultsByIndex, outcomes);
|
||||
if (!ReferenceEquals(replaced, child))
|
||||
arr[i] = replaced;
|
||||
}
|
||||
return node;
|
||||
case JTokenType.String:
|
||||
return TransformString((string)node, node, currentIndex, operations, idToIndex, resultsByIndex, outcomes);
|
||||
default:
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
private static JToken TransformString(string s, JToken original, int currentIndex,
|
||||
IReadOnlyList<BatchOperationInput> operations, IReadOnlyDictionary<string, int> idToIndex,
|
||||
JToken[] resultsByIndex, IReadOnlyList<BatchOperationResult> outcomes)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s) || s[0] != '$')
|
||||
return original; // not a reference
|
||||
|
||||
// "$$..." escapes a literal '$'. Only substitute when actually resolving; validation
|
||||
// has nothing to check for a literal.
|
||||
if (s.Length >= 2 && s[1] == '$')
|
||||
return resultsByIndex == null ? original : (JToken)new JValue(s.Substring(1));
|
||||
|
||||
var rest = s.Substring(1);
|
||||
var dot = rest.IndexOf('.');
|
||||
var selector = dot < 0 ? rest : rest.Substring(0, dot);
|
||||
var path = dot < 0 ? null : rest.Substring(dot + 1);
|
||||
|
||||
if (string.IsNullOrEmpty(selector))
|
||||
throw new ArgumentException(
|
||||
$"Invalid reference '{s}': missing operation selector after '$'. " +
|
||||
"Use \"$<id-or-index>.<field>\", or \"$$\" for a literal '$'.");
|
||||
|
||||
var targetIndex = ResolveSelector(selector, operations, idToIndex);
|
||||
if (targetIndex < 0)
|
||||
throw new ArgumentException(
|
||||
$"Reference '{s}' points to unknown operation '{selector}'. " +
|
||||
"A reference must name an earlier operation by its id or 0-based index.");
|
||||
if (targetIndex >= currentIndex)
|
||||
throw new ArgumentException(
|
||||
$"Reference '{s}' is a forward reference to operation '{selector}' (index {targetIndex}); " +
|
||||
"only backward references to earlier operations are allowed.");
|
||||
|
||||
// Validate mode: topology is fine, and there is no result to substitute yet.
|
||||
if (resultsByIndex == null)
|
||||
return original;
|
||||
|
||||
// A failed or skipped operation has no result to reference — substituting null silently
|
||||
// would feed a bogus value into this op's parameters. Error explicitly instead. In the
|
||||
// real Execute() loop only Success=false outcomes are observable here (under
|
||||
// on_error=continue — earlier failures don't stop the batch); Skipped entries are
|
||||
// appended AFTER the loop, so the "was skipped" wording is defensive, reachable only
|
||||
// when the resolver is driven directly (unit tests / future callers).
|
||||
var outcome = outcomes != null && targetIndex < outcomes.Count ? outcomes[targetIndex] : null;
|
||||
if (outcome != null && !outcome.Success)
|
||||
{
|
||||
var reason = outcome.Skipped == true ? "was skipped" : "failed";
|
||||
throw new ArgumentException(
|
||||
$"Reference '{s}' cannot be resolved: operation '{selector}' {reason}; " +
|
||||
"its result cannot be referenced.");
|
||||
}
|
||||
|
||||
// Distinct from failure: the op SUCCEEDED but produced nothing to reference (void/null
|
||||
// result, or a result that could not be captured as a token).
|
||||
var opResult = resultsByIndex[targetIndex];
|
||||
if (opResult == null || opResult.Type == JTokenType.Null)
|
||||
throw new ArgumentException(
|
||||
$"Reference '{s}' cannot be resolved: operation '{selector}' returned no result.");
|
||||
|
||||
var picked = string.IsNullOrEmpty(path) ? opResult : opResult.SelectToken(path);
|
||||
if (picked == null)
|
||||
throw new ArgumentException(
|
||||
$"Reference '{s}' did not resolve: operation '{selector}' result has no field '{path}'.");
|
||||
|
||||
return picked.DeepClone();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a selector to an operation index: an id match takes precedence over a numeric
|
||||
/// index. Returns -1 when the selector names no operation.
|
||||
/// </summary>
|
||||
private static int ResolveSelector(string selector,
|
||||
IReadOnlyList<BatchOperationInput> operations, IReadOnlyDictionary<string, int> idToIndex)
|
||||
{
|
||||
if (idToIndex != null && idToIndex.TryGetValue(selector, out var byId))
|
||||
return byId;
|
||||
if (int.TryParse(selector, out var idx) && idx >= 0 && idx < operations.Count)
|
||||
return idx;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bf4505eeb7b343d1969a2e1b8a9ab350
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Batch
|
||||
{
|
||||
/// <summary>
|
||||
/// Result envelope for the <c>batch</c> command (AUTHAPI-27). Carries a per-operation result in
|
||||
/// input order, how many ops were applied, whether a transactional rollback occurred, and the
|
||||
/// Editor Undo group the whole batch collapsed into.
|
||||
/// </summary>
|
||||
sealed class BatchResult
|
||||
{
|
||||
/// <summary>Per-operation results, in the same order as the input operations.</summary>
|
||||
[JsonProperty("results")]
|
||||
public List<BatchOperationResult> Results { get; set; } = new List<BatchOperationResult>();
|
||||
|
||||
/// <summary>Number of operations that executed successfully (before any rollback).</summary>
|
||||
[JsonProperty("applied")]
|
||||
public int Applied { get; set; }
|
||||
|
||||
/// <summary>True when a transactional rollback undid every applied operation.</summary>
|
||||
[JsonProperty("reverted")]
|
||||
public bool Reverted { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the batch ran transactionally (all ops in one Undo group, revert-on-failure).
|
||||
/// Forced false when <c>on_error=continue</c>.
|
||||
/// </summary>
|
||||
[JsonProperty("transactional")]
|
||||
public bool Transactional { get; set; }
|
||||
|
||||
/// <summary>The effective error policy: <c>abort</c> or <c>continue</c>.</summary>
|
||||
[JsonProperty("onError")]
|
||||
public string OnError { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Batch-level abort reason for stops not attributable to a single op's failure:
|
||||
/// "batch time budget exceeded" (the cooperative <c>time_budget_ms</c> ran out between ops)
|
||||
/// or "batch canceled" (cooperative job cancellation). Null otherwise.
|
||||
/// </summary>
|
||||
[JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Error { get; set; }
|
||||
|
||||
/// <summary>The Editor Undo group index the whole batch collapsed into (execution only).</summary>
|
||||
[JsonProperty("undoGroup", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public int? UndoGroup { get; set; }
|
||||
|
||||
/// <summary>True when this was a dry run (validation only, zero mutation).</summary>
|
||||
[JsonProperty("dryRun", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public bool? DryRun { get; set; }
|
||||
|
||||
/// <summary>Dry run only: whether every operation validated without error.</summary>
|
||||
[JsonProperty("valid", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public bool? Valid { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Result of a single operation within a <see cref="BatchResult"/>.</summary>
|
||||
sealed class BatchOperationResult
|
||||
{
|
||||
/// <summary>The operation's id, echoed back when supplied.</summary>
|
||||
[JsonProperty("id", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>The command that was (or would be) run.</summary>
|
||||
[JsonProperty("command")]
|
||||
public string Command { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the operation succeeded. On a dry run this is "would run" (validation passed).
|
||||
/// </summary>
|
||||
[JsonProperty("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The command's result, shaped identically to running it standalone (optionally projected
|
||||
/// via <c>result_fields</c>, or replaced by a truncation marker for oversized results).
|
||||
/// </summary>
|
||||
[JsonProperty("result", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public object Result { get; set; }
|
||||
|
||||
/// <summary>Error message when the operation failed (or failed dry-run validation).</summary>
|
||||
[JsonProperty("error", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Error { get; set; }
|
||||
|
||||
/// <summary>True for operations not attempted because an earlier op aborted the batch.</summary>
|
||||
[JsonProperty("skipped", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public bool? Skipped { get; set; }
|
||||
|
||||
/// <summary>True when the result was replaced by a truncation marker (payload economy).</summary>
|
||||
[JsonProperty("resultTruncated", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public bool? ResultTruncated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether a transactional rollback can revert this operation's mutations. False for commands
|
||||
/// that mutate state outside the Editor Undo system (asset/file/settings writes) — those only
|
||||
/// run with <c>transactional=false</c>. Unset when the command never resolved.
|
||||
/// </summary>
|
||||
[JsonProperty("revertible", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public bool? Revertible { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f14af31d9bca47ee9536e91221678a86
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bdb8ef5b0513b0d488ac3be1a9a77185
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,596 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
using UnityEditor.Build.Reporting;
|
||||
using UnityEngine;
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
using Unity.Scripting.LifecycleManagement;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Build
|
||||
{
|
||||
/// <summary>
|
||||
/// Triggers a Player build and reports the full <see cref="BuildReport"/> (CLI-204). Fills out the
|
||||
/// original <c>build</c>/<c>build_status</c> stub (CLI-127) with the complete param contract and the
|
||||
/// structured report shape so build failures can be diagnosed without the Editor UI.
|
||||
///
|
||||
/// Why this is async (trigger-then-poll, mirroring <see cref="RecompileCommand"/> /
|
||||
/// <see cref="PackageManager.PackageManagerCommand"/>): <see cref="BuildPipeline.BuildPlayer(BuildPlayerOptions)"/>
|
||||
/// runs synchronously and freezes the main thread for the whole build (seconds to minutes). If
|
||||
/// <c>build</c> ran it inline it would block the server's (serial) request loop and the call could
|
||||
/// never return. Instead <c>build</c> validates + queues and returns <c>queued</c> immediately; an
|
||||
/// <see cref="EditorApplication.update"/> hook runs the build on the next tick. Poll <c>build_status</c>
|
||||
/// until <c>status == "completed"</c>.
|
||||
///
|
||||
/// <c>build_status</c> is deliberately <c>MainThreadRequired = false</c>: while the build holds the
|
||||
/// main thread, any main-thread command (e.g. editor_status) would hang, but build_status only reads
|
||||
/// a Temp status file off-thread, so polling keeps working throughout the build. The status file also
|
||||
/// survives the domain reload a build can incur, so the last report is retained until the next build.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
[NoAutoStaticsCleanup]
|
||||
#endif
|
||||
static class BuildCommand
|
||||
{
|
||||
const string StatusFile = "Temp/pipeline_build_status.json";
|
||||
|
||||
// BuildOptions that callers may pass by name (the supported set from the ticket). Parsed
|
||||
// case-insensitively; anything outside this set is a validation error rather than silently
|
||||
// tolerated, so a typo surfaces instead of changing the build shape.
|
||||
static readonly Dictionary<string, BuildOptions> s_SupportedOptions =
|
||||
new Dictionary<string, BuildOptions>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Development"] = BuildOptions.Development,
|
||||
["AllowDebugging"] = BuildOptions.AllowDebugging,
|
||||
["ConnectWithProfiler"] = BuildOptions.ConnectWithProfiler,
|
||||
// EnableHeadlessMode is obsolete in Unity 6 (superseded by StandaloneBuildSubtarget.Server)
|
||||
// but still functions and is part of this command's documented option set (CLI-204), so we
|
||||
// keep accepting it and suppress only the deprecation warning here.
|
||||
#pragma warning disable 618
|
||||
["EnableHeadlessMode"] = BuildOptions.EnableHeadlessMode,
|
||||
#pragma warning restore 618
|
||||
["SymlinkSources"] = BuildOptions.SymlinkSources,
|
||||
["BuildAdditionalStreamedScenes"] = BuildOptions.BuildAdditionalStreamedScenes,
|
||||
["CleanBuildCache"] = BuildOptions.CleanBuildCache,
|
||||
["DetailedBuildReport"] = BuildOptions.DetailedBuildReport,
|
||||
};
|
||||
|
||||
static readonly object s_Lock = new object();
|
||||
static bool s_Pending;
|
||||
static bool s_Building;
|
||||
|
||||
// Queued request, captured under s_Lock by build() and consumed by the update pump.
|
||||
static BuildTarget s_PendingTarget;
|
||||
static string s_PendingOutput;
|
||||
static BuildOptions s_PendingOptions;
|
||||
|
||||
/// <summary>
|
||||
/// BuildOptions of the scripted build currently inside BuildPipeline.BuildPlayer, or null
|
||||
/// when no `build` command is running. Build processors read it because a scripted build
|
||||
/// with BuildOptions.Development is a development build even when the Build Settings
|
||||
/// toggle (EditorUserBuildSettings.development) is off.
|
||||
/// </summary>
|
||||
internal static BuildOptions? ActiveScriptedBuildOptions { get; private set; }
|
||||
static string[] s_PendingScenes;
|
||||
static string s_PendingProfile;
|
||||
static string s_PendingBuildId;
|
||||
|
||||
static BuildCommand()
|
||||
{
|
||||
EditorApplication.update += OnUpdate;
|
||||
}
|
||||
|
||||
[CliCommand("build",
|
||||
"Trigger an async Player build and report the full BuildReport. Returns immediately (queued); " +
|
||||
"poll build_status until status is 'completed'. DetailedBuildReport is included by default unless " +
|
||||
"'options' is supplied. Use dry_run to validate without building.",
|
||||
MainThreadRequired = false,
|
||||
Tags = new[] { "build" })]
|
||||
public static object Build(
|
||||
[CliArg("target", "BuildTarget name (e.g. StandaloneWindows64). Defaults to the active target. Must be installed.")] string target = "",
|
||||
[CliArg("outputPath", "Output path (absolute, or relative to the project root). Defaults to the last/auto path.")] string outputPath = "",
|
||||
[CliArg("profileName", "Build Profile name to activate before building (Unity 6 only; ignored otherwise).")] string profileName = "",
|
||||
[CliArg("options", "BuildOptions names. Omit to get just DetailedBuildReport; supplying any disables that default.")] string[] options = null,
|
||||
[CliArg("scenes", "Scene asset paths to build (e.g. Assets/Scenes/Main.unity). Defaults to EditorBuildSettings.")] string[] scenes = null,
|
||||
[CliArg("confirm", "Acknowledge and run the build; without it the call is refused. Use dry_run to validate only.")] bool confirm = false,
|
||||
[CliArg("dry_run", "Validate target/outputPath/scenes without building.")] bool dryRun = false)
|
||||
{
|
||||
// Validation + queueing both touch Unity APIs, so run them on the main thread. When invoked
|
||||
// off-thread (the normal HTTP path) this marshals through the server dispatcher; in-process
|
||||
// (tests, no server) it runs inline.
|
||||
return RunOnMain<object>(() => ValidateAndQueue(target, outputPath, profileName, options, scenes, confirm, dryRun));
|
||||
}
|
||||
|
||||
static object ValidateAndQueue(string targetArg, string outputArg, string profileName,
|
||||
string[] optionArgs, string[] sceneArgs, bool confirm, bool dryRun)
|
||||
{
|
||||
lock (s_Lock)
|
||||
{
|
||||
if (!dryRun && (s_Building || s_Pending))
|
||||
return new { status = "busy", success = false, message = "A build is already queued or in progress. Poll build_status." };
|
||||
}
|
||||
|
||||
var errors = new List<object>();
|
||||
|
||||
// ---- target ----
|
||||
var target = EditorUserBuildSettings.activeBuildTarget;
|
||||
if (!string.IsNullOrWhiteSpace(targetArg))
|
||||
{
|
||||
if (!TryParseTarget(targetArg, out target))
|
||||
errors.Add(Invalid("target", $"Unknown BuildTarget '{targetArg}'. Use a name from list_build_targets."));
|
||||
else if (!BuildPipeline.IsBuildTargetSupported(BuildPipeline.GetBuildTargetGroup(target), target))
|
||||
errors.Add(Invalid("target", $"Build target '{target}' is not installed (isInstalled=false in list_build_targets)."));
|
||||
}
|
||||
|
||||
// ---- options ----
|
||||
var options = BuildOptions.None;
|
||||
if (optionArgs != null && optionArgs.Length > 0)
|
||||
{
|
||||
foreach (var name in optionArgs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
continue;
|
||||
if (s_SupportedOptions.TryGetValue(name.Trim(), out var opt))
|
||||
options |= opt;
|
||||
else
|
||||
errors.Add(Invalid("options", $"Unsupported BuildOptions value '{name}'."));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Strongly recommended: without it BuildReport.packedAssets is empty.
|
||||
options = BuildOptions.DetailedBuildReport;
|
||||
}
|
||||
|
||||
// ---- scenes ----
|
||||
var scenes = (sceneArgs != null && sceneArgs.Length > 0)
|
||||
? sceneArgs
|
||||
: EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray();
|
||||
|
||||
if (sceneArgs != null)
|
||||
{
|
||||
foreach (var scene in sceneArgs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scene) || string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(scene)))
|
||||
errors.Add(Invalid("scenes", $"No scene asset at '{scene}'."));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- output path ----
|
||||
// Non-mutating writability check: resolve the path and confirm an existing ancestor
|
||||
// directory exists (so the build's output folder can be created). BuildPipeline.BuildPlayer
|
||||
// creates the leaf directory itself, so we never create anything here — a dry run stays pure.
|
||||
string outputPath = null;
|
||||
try
|
||||
{
|
||||
outputPath = ResolveOutput(outputArg, target);
|
||||
if (!HasExistingAncestor(outputPath))
|
||||
errors.Add(Invalid("outputPath", $"No existing parent directory for '{outputPath}'."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(Invalid("outputPath", $"Output path is invalid: {ex.Message}"));
|
||||
}
|
||||
|
||||
if (dryRun)
|
||||
{
|
||||
return new
|
||||
{
|
||||
status = "dry_run",
|
||||
valid = errors.Count == 0,
|
||||
validationErrors = errors
|
||||
};
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
// Fail immediately — do not queue (acceptance criteria 5).
|
||||
return new { status = "error", success = false, validationErrors = errors, message = "Build configuration is invalid; nothing was queued." };
|
||||
}
|
||||
|
||||
if (!confirm)
|
||||
return new
|
||||
{
|
||||
status = "error",
|
||||
success = false,
|
||||
message = "Refused: this triggers a player build. Pass confirm=true to build, or dry_run=true to validate without building."
|
||||
};
|
||||
|
||||
var buildId = NewBuildId();
|
||||
|
||||
lock (s_Lock)
|
||||
{
|
||||
s_PendingTarget = target;
|
||||
s_PendingOutput = outputPath;
|
||||
s_PendingOptions = options;
|
||||
s_PendingScenes = scenes;
|
||||
s_PendingProfile = profileName;
|
||||
s_PendingBuildId = buildId;
|
||||
s_Pending = true;
|
||||
}
|
||||
|
||||
WriteStatusJson(new { status = "queued", buildId, message = "Build queued. Poll build_status until status is 'completed'." });
|
||||
|
||||
return new { status = "queued", buildId };
|
||||
}
|
||||
|
||||
[CliCommand("build_status",
|
||||
"Status of the current/most recent build: idle | queued | building | completed, with the full " +
|
||||
"BuildReport (files, packedAssets, buildSteps, errors, warnings) once completed. Retained until the next build.",
|
||||
MainThreadRequired = false,
|
||||
Tags = new[] { "build" })]
|
||||
public static string BuildStatus()
|
||||
{
|
||||
if (!File.Exists(StatusFile))
|
||||
return "{\"status\":\"idle\"}";
|
||||
|
||||
var text = File.ReadAllText(StatusFile);
|
||||
|
||||
// While building, recompute elapsedMs at read time so polling shows live progress.
|
||||
try
|
||||
{
|
||||
var doc = JObject.Parse(text);
|
||||
if ((string)doc["status"] == "building")
|
||||
{
|
||||
long elapsed = 0;
|
||||
var startedStr = (string)doc["buildStartedAt"];
|
||||
if (!string.IsNullOrEmpty(startedStr) &&
|
||||
DateTime.TryParse(startedStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var started))
|
||||
{
|
||||
elapsed = (long)Math.Max(0, (DateTime.UtcNow - started.ToUniversalTime()).TotalMilliseconds);
|
||||
}
|
||||
|
||||
return JsonConvert.SerializeObject(new
|
||||
{
|
||||
status = "building",
|
||||
buildId = (string)doc["buildId"],
|
||||
elapsedMs = elapsed
|
||||
});
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Malformed file — fall through and return it verbatim.
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main-thread pump: picks up a queued request and runs the (blocking) build. Guarded so a build
|
||||
/// runs at most once at a time.
|
||||
/// </summary>
|
||||
static void OnUpdate()
|
||||
{
|
||||
BuildTarget target;
|
||||
string output;
|
||||
BuildOptions options;
|
||||
string[] scenes;
|
||||
string profile;
|
||||
string buildId;
|
||||
|
||||
lock (s_Lock)
|
||||
{
|
||||
if (!s_Pending || s_Building)
|
||||
return;
|
||||
s_Pending = false;
|
||||
s_Building = true;
|
||||
target = s_PendingTarget;
|
||||
output = s_PendingOutput;
|
||||
options = s_PendingOptions;
|
||||
scenes = s_PendingScenes;
|
||||
profile = s_PendingProfile;
|
||||
buildId = s_PendingBuildId;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
RunBuild(buildId, target, output, options, scenes, profile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteStatusJson(new
|
||||
{
|
||||
status = "completed",
|
||||
buildId,
|
||||
result = "Failed",
|
||||
totalSizeBytes = 0,
|
||||
errors = new[] { new { message = $"Build failed to start: {ex.Message}" } },
|
||||
warnings = Array.Empty<object>()
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (s_Lock) { s_Building = false; }
|
||||
}
|
||||
}
|
||||
|
||||
static void RunBuild(string buildId, BuildTarget target, string outputPath, BuildOptions options,
|
||||
string[] scenes, string profileName)
|
||||
{
|
||||
var startedAt = DateTime.UtcNow;
|
||||
WriteStatusJson(new
|
||||
{
|
||||
status = "building",
|
||||
buildId,
|
||||
buildStartedAt = startedAt.ToString("o"),
|
||||
platform = target.ToString(),
|
||||
outputPath
|
||||
});
|
||||
|
||||
// Best-effort Build Profile activation (Unity 6 only). Never fails the build.
|
||||
if (!string.IsNullOrWhiteSpace(profileName))
|
||||
TryActivateBuildProfile(profileName);
|
||||
|
||||
var buildOptions = new BuildPlayerOptions
|
||||
{
|
||||
scenes = scenes,
|
||||
locationPathName = outputPath,
|
||||
target = target,
|
||||
targetGroup = BuildPipeline.GetBuildTargetGroup(target),
|
||||
options = options
|
||||
};
|
||||
|
||||
ActiveScriptedBuildOptions = options;
|
||||
BuildReport report;
|
||||
try { report = BuildPipeline.BuildPlayer(buildOptions); }
|
||||
finally { ActiveScriptedBuildOptions = null; }
|
||||
WriteStatusJson(Project(buildId, report));
|
||||
}
|
||||
|
||||
// ---- BuildReport projection ------------------------------------------------------------
|
||||
|
||||
static BuildReportResult Project(string buildId, BuildReport report)
|
||||
{
|
||||
var summary = report.summary;
|
||||
var success = summary.result == BuildResult.Succeeded;
|
||||
var started = summary.buildStartedAt;
|
||||
var ended = started + summary.totalTime;
|
||||
|
||||
var result = new BuildReportResult
|
||||
{
|
||||
Status = "completed",
|
||||
BuildId = buildId,
|
||||
Result = summary.result.ToString(),
|
||||
Platform = summary.platform.ToString(),
|
||||
OutputPath = summary.outputPath,
|
||||
TotalSizeBytes = (long)summary.totalSize,
|
||||
BuildTimeMs = (long)summary.totalTime.TotalMilliseconds,
|
||||
BuildStartedAt = started.ToUniversalTime().ToString("o"),
|
||||
BuildEndedAt = ended.ToUniversalTime().ToString("o"),
|
||||
TotalWarnings = summary.totalWarnings,
|
||||
TotalErrors = summary.totalErrors,
|
||||
BuildSteps = ProjectSteps(report, out var errors, out var warnings),
|
||||
Errors = errors,
|
||||
Warnings = warnings
|
||||
};
|
||||
|
||||
// Files / packed assets are only meaningful on success (and packedAssets needs DetailedBuildReport).
|
||||
if (success)
|
||||
{
|
||||
result.Files = ProjectFiles(report);
|
||||
result.PackedAssets = ProjectPackedAssets(report);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static List<BuildFileEntry> ProjectFiles(BuildReport report)
|
||||
{
|
||||
var files = new List<BuildFileEntry>();
|
||||
try
|
||||
{
|
||||
foreach (var f in report.GetFiles())
|
||||
files.Add(new BuildFileEntry { Path = f.path, Role = f.role, SizeBytes = (long)f.size });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[pipeline] build file projection failed: {ex.Message}");
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
static List<PackedAssetEntry> ProjectPackedAssets(BuildReport report)
|
||||
{
|
||||
var packed = new List<PackedAssetEntry>();
|
||||
if (report.packedAssets == null)
|
||||
return packed;
|
||||
|
||||
foreach (var bundle in report.packedAssets)
|
||||
{
|
||||
var entry = new PackedAssetEntry
|
||||
{
|
||||
BundlePath = bundle.shortPath,
|
||||
OverheadBytes = (long)bundle.overhead,
|
||||
Contents = new List<PackedAssetContent>()
|
||||
};
|
||||
|
||||
if (bundle.contents != null)
|
||||
{
|
||||
foreach (var c in bundle.contents)
|
||||
{
|
||||
entry.Contents.Add(new PackedAssetContent
|
||||
{
|
||||
AssetPath = c.sourceAssetPath,
|
||||
Type = c.type != null ? c.type.Name : null,
|
||||
Guid = c.sourceAssetGUID.ToString(),
|
||||
SizeBytes = (long)c.packedSize
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
packed.Add(entry);
|
||||
}
|
||||
return packed;
|
||||
}
|
||||
|
||||
static List<BuildStepEntry> ProjectSteps(BuildReport report,
|
||||
out List<BuildIssue> errors, out List<BuildIssue> warnings)
|
||||
{
|
||||
errors = new List<BuildIssue>();
|
||||
warnings = new List<BuildIssue>();
|
||||
|
||||
var steps = new List<BuildStepEntry>();
|
||||
if (report.steps == null)
|
||||
return steps;
|
||||
|
||||
foreach (var step in report.steps)
|
||||
{
|
||||
var entry = new BuildStepEntry
|
||||
{
|
||||
Name = step.name,
|
||||
DurationMs = (long)step.duration.TotalMilliseconds,
|
||||
Depth = (int)step.depth,
|
||||
Messages = new List<BuildStepMessageEntry>()
|
||||
};
|
||||
|
||||
if (step.messages != null)
|
||||
{
|
||||
foreach (var m in step.messages)
|
||||
{
|
||||
entry.Messages.Add(new BuildStepMessageEntry { Type = MapLogType(m.type), Content = m.content });
|
||||
|
||||
if (m.type == LogType.Error || m.type == LogType.Assert || m.type == LogType.Exception)
|
||||
errors.Add(BuildIssue.From(m.content));
|
||||
else if (m.type == LogType.Warning)
|
||||
warnings.Add(BuildIssue.From(m.content));
|
||||
}
|
||||
}
|
||||
|
||||
steps.Add(entry);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
static string MapLogType(LogType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case LogType.Warning: return "Warning";
|
||||
case LogType.Error:
|
||||
case LogType.Assert:
|
||||
case LogType.Exception: return "Error";
|
||||
default: return "Log";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Case-insensitive parse of a <see cref="BuildTarget"/> enum name.</summary>
|
||||
static bool TryParseTarget(string name, out BuildTarget target)
|
||||
{
|
||||
try
|
||||
{
|
||||
target = (BuildTarget)Enum.Parse(typeof(BuildTarget), name.Trim(), ignoreCase: true);
|
||||
return Enum.IsDefined(typeof(BuildTarget), target);
|
||||
}
|
||||
catch
|
||||
{
|
||||
target = EditorUserBuildSettings.activeBuildTarget;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static object Invalid(string field, string message) => new { field, message };
|
||||
|
||||
static string NewBuildId() => "build_" + Guid.NewGuid().ToString("N").Substring(0, 12);
|
||||
|
||||
static string ResolveOutput(string output, BuildTarget target)
|
||||
{
|
||||
var projectRoot = ProjectPaths.ProjectRoot;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
return Path.IsPathRooted(output) ? output : Path.GetFullPath(Path.Combine(projectRoot, output));
|
||||
|
||||
var product = string.IsNullOrEmpty(PlayerSettings.productName) ? "Player" : PlayerSettings.productName;
|
||||
return Path.Combine(projectRoot, "Builds", target.ToString(), product + ExtensionFor(target));
|
||||
}
|
||||
|
||||
/// <summary>True if some ancestor directory of <paramref name="path"/> already exists — a cheap,
|
||||
/// non-mutating check that the build output folder is creatable.</summary>
|
||||
static bool HasExistingAncestor(string path)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
while (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
if (Directory.Exists(dir))
|
||||
return true;
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static string ExtensionFor(BuildTarget target)
|
||||
{
|
||||
switch (target)
|
||||
{
|
||||
case BuildTarget.StandaloneWindows:
|
||||
case BuildTarget.StandaloneWindows64:
|
||||
return ".exe";
|
||||
case BuildTarget.StandaloneOSX:
|
||||
return ".app";
|
||||
case BuildTarget.Android:
|
||||
return ".apk";
|
||||
default:
|
||||
return string.Empty; // Linux standalone, dedicated server, etc.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort: activate a Build Profile by name via reflection so the assembly still compiles on
|
||||
/// Unity versions without the Build Profile API. Any failure is logged and ignored — the build
|
||||
/// proceeds with the current configuration.
|
||||
/// </summary>
|
||||
static void TryActivateBuildProfile(string profileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = BuildProfiles.FindByName(profileName);
|
||||
if (info == null)
|
||||
{
|
||||
Debug.LogWarning($"[pipeline] build: profile '{profileName}' not found; building with current settings.");
|
||||
return;
|
||||
}
|
||||
if (!BuildProfiles.TrySetActive(info))
|
||||
Debug.LogWarning($"[pipeline] build: could not activate profile '{profileName}'; building with current settings.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[pipeline] build: profile activation failed ({ex.Message}); building with current settings.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run <paramref name="fn"/> on the Unity main thread. Off-thread (the HTTP path) it marshals
|
||||
/// through the live server's dispatcher; on the main thread (a direct in-process/test call, or no
|
||||
/// server running) it runs inline. Mirrors <see cref="PackageManager.PackageManagerCommand"/>.
|
||||
/// </summary>
|
||||
static T RunOnMain<T>(Func<T> fn)
|
||||
{
|
||||
var dispatcher = PipelineServerStartup.Server?.Dispatcher;
|
||||
if (dispatcher != null && dispatcher.IsInitialized && !dispatcher.IsMainThread())
|
||||
return dispatcher.Invoke(fn);
|
||||
return fn();
|
||||
}
|
||||
|
||||
static void WriteStatusJson(object status)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[Build] Failed to write status file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e718f193c77f724ea4de47fe8a2a3cb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,347 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
using UnityEngine;
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
using Unity.Scripting.LifecycleManagement;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Build
|
||||
{
|
||||
/// <summary>
|
||||
/// Read/inspect build configuration for agents (CLI-204): enumerate targets, read/write the mutable
|
||||
/// <c>EditorUserBuildSettings</c> fields, and list Build Profile assets. Scene-list management and
|
||||
/// target switching are intentionally elsewhere (<c>add_scene_to_build</c>/<c>remove_scene_from_build</c>
|
||||
/// from CLI-189, and <c>switch_build_target</c>).
|
||||
/// </summary>
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
[NoAutoStaticsCleanup]
|
||||
#endif
|
||||
static class BuildConfigCommands
|
||||
{
|
||||
[CliCommand("list_build_targets",
|
||||
"List the known BuildTarget values with their group and whether build support is installed.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "build/targets" })]
|
||||
public static object ListBuildTargets()
|
||||
{
|
||||
var targets = new List<BuildTargetInfo>();
|
||||
var seen = new HashSet<string>();
|
||||
|
||||
foreach (BuildTarget value in Enum.GetValues(typeof(BuildTarget)))
|
||||
{
|
||||
// Exclude NoTarget / internal sentinels (negative) and deprecated/obsolete values.
|
||||
if ((int)value <= 0)
|
||||
continue;
|
||||
|
||||
var name = value.ToString();
|
||||
if (!seen.Add(name))
|
||||
continue;
|
||||
|
||||
var field = typeof(BuildTarget).GetField(name);
|
||||
if (field != null && field.GetCustomAttribute<ObsoleteAttribute>() != null)
|
||||
continue;
|
||||
|
||||
BuildTargetGroup group;
|
||||
try { group = BuildPipeline.GetBuildTargetGroup(value); }
|
||||
catch { continue; }
|
||||
|
||||
bool installed;
|
||||
try { installed = BuildPipeline.IsBuildTargetSupported(group, value); }
|
||||
catch { installed = false; }
|
||||
|
||||
targets.Add(new BuildTargetInfo
|
||||
{
|
||||
Name = name,
|
||||
DisplayName = DisplayNameFor(value),
|
||||
TargetGroup = group.ToString(),
|
||||
IsInstalled = installed
|
||||
});
|
||||
}
|
||||
|
||||
return targets.OrderBy(t => t.TargetGroup).ThenBy(t => t.Name).ToList();
|
||||
}
|
||||
|
||||
[CliCommand("get_build_settings",
|
||||
"Read the current build configuration from EditorUserBuildSettings / EditorBuildSettings.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "build/settings" })]
|
||||
public static object GetBuildSettings()
|
||||
{
|
||||
var target = EditorUserBuildSettings.activeBuildTarget;
|
||||
var group = BuildPipeline.GetBuildTargetGroup(target);
|
||||
|
||||
return new BuildSettingsResult
|
||||
{
|
||||
ActiveBuildTarget = target.ToString(),
|
||||
ActiveBuildTargetGroup = group.ToString(),
|
||||
DevelopmentBuild = EditorUserBuildSettings.development,
|
||||
AllowDebugging = EditorUserBuildSettings.allowDebugging,
|
||||
ConnectWithProfiler = EditorUserBuildSettings.connectProfiler,
|
||||
BuildScriptsOnly = EditorUserBuildSettings.buildScriptsOnly,
|
||||
SymlinkSources = EditorUserBuildSettings.symlinkSources,
|
||||
Il2CppCodeGeneration = ReadIl2CppCodeGeneration(group),
|
||||
Scenes = EditorBuildSettings.scenes.Select(s => new BuildSceneEntry
|
||||
{
|
||||
Path = s.path,
|
||||
Guid = s.guid.ToString(),
|
||||
Enabled = s.enabled
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
[CliCommand("set_build_settings",
|
||||
"Set mutable EditorUserBuildSettings fields. Does NOT manage scenes (use add_scene_to_build / " +
|
||||
"remove_scene_from_build) or switch target (use switch_build_target). Use dry_run to preview.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "build/settings" })]
|
||||
public static object SetBuildSettings(
|
||||
[CliArg("settings", "Fields to change; omitted fields are left unchanged.")] SetBuildSettingsInput settings = null,
|
||||
[CliArg("confirm", "Apply the changes. Without it the call is refused.")] bool confirm = false,
|
||||
[CliArg("dry_run", "Preview the change without applying it.")] bool dryRun = false)
|
||||
{
|
||||
if (settings == null)
|
||||
return SetBuildSettingsResult.Fail("No 'settings' object provided.");
|
||||
|
||||
if (!dryRun && !confirm)
|
||||
return SetBuildSettingsResult.Fail(
|
||||
"Refused: this operation mutates the project. Re-run with confirm=true to apply, or dry_run=true to preview the change.");
|
||||
var result = new SetBuildSettingsResult { DryRun = dryRun, Success = true };
|
||||
var group = BuildPipeline.GetBuildTargetGroup(EditorUserBuildSettings.activeBuildTarget);
|
||||
|
||||
// For each supplied field: change it (recording in Applied) only if it differs from the
|
||||
// current value; otherwise record it in Skipped as a no-op.
|
||||
Plan(result, "developmentBuild", settings.DevelopmentBuild, EditorUserBuildSettings.development,
|
||||
v => EditorUserBuildSettings.development = v, dryRun);
|
||||
Plan(result, "allowDebugging", settings.AllowDebugging, EditorUserBuildSettings.allowDebugging,
|
||||
v => EditorUserBuildSettings.allowDebugging = v, dryRun);
|
||||
Plan(result, "connectWithProfiler", settings.ConnectWithProfiler, EditorUserBuildSettings.connectProfiler,
|
||||
v => EditorUserBuildSettings.connectProfiler = v, dryRun);
|
||||
Plan(result, "buildScriptsOnly", settings.BuildScriptsOnly, EditorUserBuildSettings.buildScriptsOnly,
|
||||
v => EditorUserBuildSettings.buildScriptsOnly = v, dryRun);
|
||||
Plan(result, "symlinkSources", settings.SymlinkSources, EditorUserBuildSettings.symlinkSources,
|
||||
v => EditorUserBuildSettings.symlinkSources = v, dryRun);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Il2CppCodeGeneration))
|
||||
{
|
||||
if (!TryParseIl2Cpp(settings.Il2CppCodeGeneration, out var requested))
|
||||
return SetBuildSettingsResult.Fail($"Invalid il2CppCodeGeneration '{settings.Il2CppCodeGeneration}'. Use OptimizeSpeed | OptimizeSize.");
|
||||
|
||||
var current = ReadIl2CppCodeGeneration(group);
|
||||
if (!string.Equals(current, requested.ToString(), StringComparison.Ordinal))
|
||||
{
|
||||
if (!dryRun)
|
||||
PlayerSettings.SetIl2CppCodeGeneration(NamedBuildTarget.FromBuildTargetGroup(group), requested);
|
||||
result.Applied["il2CppCodeGeneration"] = requested.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Skipped["il2CppCodeGeneration"] = current;
|
||||
}
|
||||
}
|
||||
|
||||
result.Message = dryRun
|
||||
? $"Dry run — {result.Applied.Count} field(s) would change, {result.Skipped.Count} unchanged."
|
||||
: $"Applied {result.Applied.Count} field(s); {result.Skipped.Count} unchanged.";
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("list_build_profiles",
|
||||
"List Build Profile assets in the project (Unity 6 only). Returns feature_unavailable on earlier versions.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "build/settings" })]
|
||||
public static object ListBuildProfiles()
|
||||
{
|
||||
if (!BuildProfiles.IsSupported)
|
||||
return new { error = "Build Profiles require Unity 6 (6000.0) or later", code = "feature_unavailable" };
|
||||
|
||||
return BuildProfiles.List();
|
||||
}
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------------------
|
||||
|
||||
static void Plan(SetBuildSettingsResult result, string name, bool? requested, bool current,
|
||||
Action<bool> set, bool dryRun)
|
||||
{
|
||||
if (!requested.HasValue)
|
||||
return;
|
||||
|
||||
if (requested.Value != current)
|
||||
{
|
||||
if (!dryRun)
|
||||
set(requested.Value);
|
||||
result.Applied[name] = requested.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Skipped[name] = current;
|
||||
}
|
||||
}
|
||||
|
||||
static string ReadIl2CppCodeGeneration(BuildTargetGroup group)
|
||||
{
|
||||
try
|
||||
{
|
||||
return PlayerSettings.GetIl2CppCodeGeneration(NamedBuildTarget.FromBuildTargetGroup(group)).ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Il2CppCodeGeneration.OptimizeSpeed.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
static bool TryParseIl2Cpp(string value, out Il2CppCodeGeneration parsed)
|
||||
{
|
||||
return Enum.TryParse(value.Trim(), ignoreCase: true, out parsed)
|
||||
&& Enum.IsDefined(typeof(Il2CppCodeGeneration), parsed);
|
||||
}
|
||||
|
||||
/// <summary>Friendly label for common targets; falls back to the enum name.</summary>
|
||||
static string DisplayNameFor(BuildTarget target)
|
||||
{
|
||||
switch (target)
|
||||
{
|
||||
case BuildTarget.StandaloneWindows: return "Windows (32-bit)";
|
||||
case BuildTarget.StandaloneWindows64: return "Windows 64-bit";
|
||||
case BuildTarget.StandaloneOSX: return "macOS";
|
||||
case BuildTarget.StandaloneLinux64: return "Linux 64-bit";
|
||||
case BuildTarget.Android: return "Android";
|
||||
case BuildTarget.iOS: return "iOS";
|
||||
case BuildTarget.tvOS: return "tvOS";
|
||||
case BuildTarget.WebGL: return "WebGL";
|
||||
case BuildTarget.WSAPlayer: return "Universal Windows Platform";
|
||||
case BuildTarget.PS4: return "PlayStation 4";
|
||||
case BuildTarget.PS5: return "PlayStation 5";
|
||||
case BuildTarget.XboxOne: return "Xbox One";
|
||||
case BuildTarget.Switch: return "Nintendo Switch";
|
||||
default: return target.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reflection shim over the Unity 6 Build Profile API (<c>UnityEditor.Build.Profile.BuildProfile</c>),
|
||||
/// so this assembly compiles and runs on older editors without the type. Used by
|
||||
/// <c>list_build_profiles</c> and by <c>build</c>'s optional <c>profileName</c> activation.
|
||||
/// </summary>
|
||||
internal static class BuildProfiles
|
||||
{
|
||||
const string TypeName = "UnityEditor.Build.Profile.BuildProfile, UnityEditor.CoreModule";
|
||||
|
||||
static readonly Type s_Type = ResolveType();
|
||||
|
||||
public static bool IsSupported => s_Type != null;
|
||||
|
||||
static Type ResolveType()
|
||||
{
|
||||
var t = Type.GetType(TypeName);
|
||||
if (t != null)
|
||||
return t;
|
||||
|
||||
// Fall back to a full-assembly scan — the assembly-qualified name can vary across versions.
|
||||
foreach (var asm in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
t = asm.GetType("UnityEditor.Build.Profile.BuildProfile");
|
||||
if (t != null)
|
||||
return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<BuildProfileInfo> List()
|
||||
{
|
||||
var list = new List<BuildProfileInfo>();
|
||||
if (s_Type == null)
|
||||
return list;
|
||||
|
||||
var active = GetActive();
|
||||
foreach (var guid in AssetDatabase.FindAssets("t:BuildProfile"))
|
||||
{
|
||||
var path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var asset = AssetDatabase.LoadAssetAtPath(path, s_Type);
|
||||
if (asset == null)
|
||||
continue;
|
||||
|
||||
list.Add(new BuildProfileInfo
|
||||
{
|
||||
Name = System.IO.Path.GetFileNameWithoutExtension(path),
|
||||
Guid = guid,
|
||||
Platform = ReadPlatform(asset),
|
||||
IsActive = active != null && ReferenceEquals(active, asset)
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public static BuildProfileInfo FindByName(string name)
|
||||
{
|
||||
return List().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>Activate the named profile via the API's static setter; returns false if unavailable.</summary>
|
||||
public static bool TrySetActive(BuildProfileInfo info)
|
||||
{
|
||||
if (s_Type == null || info == null)
|
||||
return false;
|
||||
|
||||
var path = AssetDatabase.GUIDToAssetPath(info.Guid);
|
||||
var asset = AssetDatabase.LoadAssetAtPath(path, s_Type);
|
||||
if (asset == null)
|
||||
return false;
|
||||
|
||||
// Unity 6 exposes a static settable 'activeProfile' (and/or a SetActiveBuildProfile method).
|
||||
var prop = s_Type.GetProperty("activeProfile", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop != null && prop.CanWrite)
|
||||
{
|
||||
prop.SetValue(null, asset);
|
||||
return true;
|
||||
}
|
||||
|
||||
var method = s_Type.GetMethod("SetActiveBuildProfile", BindingFlags.Public | BindingFlags.Static);
|
||||
if (method != null)
|
||||
{
|
||||
method.Invoke(null, new[] { asset });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static UnityEngine.Object GetActive()
|
||||
{
|
||||
try
|
||||
{
|
||||
var prop = s_Type.GetProperty("activeProfile", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop != null)
|
||||
return prop.GetValue(null) as UnityEngine.Object;
|
||||
|
||||
var method = s_Type.GetMethod("GetActiveBuildProfile", BindingFlags.Public | BindingFlags.Static);
|
||||
if (method != null)
|
||||
return method.Invoke(null, null) as UnityEngine.Object;
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
static string ReadPlatform(UnityEngine.Object asset)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Public property first, then the serialized backing field.
|
||||
var prop = s_Type.GetProperty("buildTarget", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (prop != null)
|
||||
return prop.GetValue(asset)?.ToString() ?? string.Empty;
|
||||
|
||||
var field = s_Type.GetField("m_BuildTarget", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (field != null)
|
||||
return field.GetValue(asset)?.ToString() ?? string.Empty;
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e65c0b78842b492d8f9363ec749bc370
|
||||
@@ -0,0 +1,271 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Build
|
||||
{
|
||||
/// <summary>
|
||||
/// One entry of <c>list_build_targets</c> (CLI-204): a <c>BuildTarget</c> enum value with the
|
||||
/// information an agent needs to choose a valid target without guessing — its group and whether the
|
||||
/// platform's build support is actually installed in this editor.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
class BuildTargetInfo
|
||||
{
|
||||
/// <summary><c>BuildTarget</c> enum name, e.g. "StandaloneWindows64".</summary>
|
||||
[JsonProperty("name")] public string Name { get; set; }
|
||||
|
||||
/// <summary>Friendly label, e.g. "Windows 64-bit".</summary>
|
||||
[JsonProperty("displayName")] public string DisplayName { get; set; }
|
||||
|
||||
/// <summary><c>BuildTargetGroup</c> enum name, e.g. "Standalone".</summary>
|
||||
[JsonProperty("targetGroup")] public string TargetGroup { get; set; }
|
||||
|
||||
/// <summary><c>BuildPipeline.IsBuildTargetSupported(group, target)</c> — module installed and usable.</summary>
|
||||
[JsonProperty("isInstalled")] public bool IsInstalled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One scene in the Build Settings scene list, projected for <c>get_build_settings</c>.</summary>
|
||||
[Serializable]
|
||||
class BuildSceneEntry
|
||||
{
|
||||
[JsonProperty("path")] public string Path { get; set; }
|
||||
[JsonProperty("guid")] public string Guid { get; set; }
|
||||
[JsonProperty("enabled")] public bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <c>get_build_settings</c> (CLI-204): the current build configuration read from
|
||||
/// <c>EditorUserBuildSettings</c> / <c>EditorBuildSettings</c> plus the active target's IL2CPP code
|
||||
/// generation mode. Scene-list management lives in <c>add_scene_to_build</c> /
|
||||
/// <c>remove_scene_from_build</c> (CLI-189); this only reports the list.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
class BuildSettingsResult
|
||||
{
|
||||
[JsonProperty("activeBuildTarget")] public string ActiveBuildTarget { get; set; }
|
||||
[JsonProperty("activeBuildTargetGroup")] public string ActiveBuildTargetGroup { get; set; }
|
||||
[JsonProperty("developmentBuild")] public bool DevelopmentBuild { get; set; }
|
||||
[JsonProperty("allowDebugging")] public bool AllowDebugging { get; set; }
|
||||
[JsonProperty("connectWithProfiler")] public bool ConnectWithProfiler { get; set; }
|
||||
[JsonProperty("buildScriptsOnly")] public bool BuildScriptsOnly { get; set; }
|
||||
[JsonProperty("symlinkSources")] public bool SymlinkSources { get; set; }
|
||||
|
||||
/// <summary>"OptimizeSpeed" | "OptimizeSize" for the active build target.</summary>
|
||||
[JsonProperty("il2CppCodeGeneration")] public string Il2CppCodeGeneration { get; set; }
|
||||
|
||||
[JsonProperty("scenes")] public List<BuildSceneEntry> Scenes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mutable build-settings fields for <c>set_build_settings</c> (CLI-204). Every field is nullable;
|
||||
/// only the ones the caller supplies are changed. Note that <see cref="AllowDebugging"/> and
|
||||
/// <see cref="ConnectWithProfiler"/> only take effect when <see cref="DevelopmentBuild"/> is also on.
|
||||
/// </summary>
|
||||
class SetBuildSettingsInput : IStructuredCommandInput
|
||||
{
|
||||
[CliArg("developmentBuild", "Build a Development Player (enables the debugger/profiler).")]
|
||||
public bool? DevelopmentBuild { get; set; }
|
||||
|
||||
[CliArg("allowDebugging", "Allow script debugging (only effective with developmentBuild=true).")]
|
||||
public bool? AllowDebugging { get; set; }
|
||||
|
||||
[CliArg("connectWithProfiler", "Auto-connect the Profiler (only effective with developmentBuild=true).")]
|
||||
public bool? ConnectWithProfiler { get; set; }
|
||||
|
||||
[CliArg("buildScriptsOnly", "Build only the scripts (skip data) for faster iteration.")]
|
||||
public bool? BuildScriptsOnly { get; set; }
|
||||
|
||||
[CliArg("symlinkSources", "Symlink runtime/plugin sources instead of copying (where supported).")]
|
||||
public bool? SymlinkSources { get; set; }
|
||||
|
||||
[CliArg("il2CppCodeGeneration", "IL2CPP code generation for the active target: OptimizeSpeed | OptimizeSize.")]
|
||||
public string Il2CppCodeGeneration { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <c>set_build_settings</c> (CLI-204): the fields that were actually changed
|
||||
/// (<see cref="Applied"/>) versus the supplied fields that already had the requested value
|
||||
/// (<see cref="Skipped"/>). A dry run reports the same <see cref="Applied"/> set it <em>would</em>
|
||||
/// write, without changing anything.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
class SetBuildSettingsResult
|
||||
{
|
||||
[JsonProperty("success")] public bool Success { get; set; }
|
||||
[JsonProperty("dryRun")] public bool DryRun { get; set; }
|
||||
|
||||
/// <summary>Field name → new value, for each field that changed (or would change on a dry run).</summary>
|
||||
[JsonProperty("applied")] public Dictionary<string, object> Applied { get; set; } = new Dictionary<string, object>();
|
||||
|
||||
/// <summary>Field name → current value, for supplied fields that already matched (no-ops).</summary>
|
||||
[JsonProperty("skipped")] public Dictionary<string, object> Skipped { get; set; } = new Dictionary<string, object>();
|
||||
|
||||
[JsonProperty("message")] public string Message { get; set; }
|
||||
|
||||
public static SetBuildSettingsResult Fail(string message) =>
|
||||
new SetBuildSettingsResult { Success = false, Message = message };
|
||||
}
|
||||
|
||||
/// <summary>One Build Profile asset, projected for <c>list_build_profiles</c> (Unity 6 only).</summary>
|
||||
[Serializable]
|
||||
class BuildProfileInfo
|
||||
{
|
||||
[JsonProperty("name")] public string Name { get; set; }
|
||||
[JsonProperty("guid")] public string Guid { get; set; }
|
||||
|
||||
/// <summary>The profile's build target name (best-effort; may be empty if unreadable).</summary>
|
||||
[JsonProperty("platform")] public string Platform { get; set; }
|
||||
|
||||
[JsonProperty("isActive")] public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One output file produced by the build (a row of <c>BuildReport.GetFiles()</c>).</summary>
|
||||
[Serializable]
|
||||
class BuildFileEntry
|
||||
{
|
||||
[JsonProperty("path")] public string Path { get; set; }
|
||||
|
||||
/// <summary><c>BuildFile.role</c> verbatim, e.g. "MainData", "AssetBundle", "BootConfig".</summary>
|
||||
[JsonProperty("role")] public string Role { get; set; }
|
||||
|
||||
[JsonProperty("sizeBytes")] public long SizeBytes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One asset packed into a build bundle/file (a row of a <c>PackedAssets</c> entry).</summary>
|
||||
[Serializable]
|
||||
class PackedAssetContent
|
||||
{
|
||||
[JsonProperty("assetPath")] public string AssetPath { get; set; }
|
||||
[JsonProperty("type")] public string Type { get; set; }
|
||||
[JsonProperty("guid")] public string Guid { get; set; }
|
||||
[JsonProperty("sizeBytes")] public long SizeBytes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A packed bundle/file with its per-asset size breakdown (requires DetailedBuildReport).</summary>
|
||||
[Serializable]
|
||||
class PackedAssetEntry
|
||||
{
|
||||
[JsonProperty("bundlePath")] public string BundlePath { get; set; }
|
||||
[JsonProperty("overheadBytes")] public long OverheadBytes { get; set; }
|
||||
[JsonProperty("contents")] public List<PackedAssetContent> Contents { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One message emitted during a build step.</summary>
|
||||
[Serializable]
|
||||
class BuildStepMessageEntry
|
||||
{
|
||||
/// <summary>"Log" | "Warning" | "Error" (mapped from <c>LogType</c>).</summary>
|
||||
[JsonProperty("type")] public string Type { get; set; }
|
||||
[JsonProperty("content")] public string Content { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One step of the build (a row of <c>BuildReport.steps</c>).</summary>
|
||||
[Serializable]
|
||||
class BuildStepEntry
|
||||
{
|
||||
[JsonProperty("name")] public string Name { get; set; }
|
||||
[JsonProperty("durationMs")] public long DurationMs { get; set; }
|
||||
[JsonProperty("depth")] public int Depth { get; set; }
|
||||
[JsonProperty("messages")] public List<BuildStepMessageEntry> Messages { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A build error/warning surfaced in <c>build_status</c>, with best-effort source file.</summary>
|
||||
[Serializable]
|
||||
class BuildIssue
|
||||
{
|
||||
[JsonProperty("message")] public string Message { get; set; }
|
||||
|
||||
/// <summary>Source file when the message carries a "File.cs(line,col): ..." prefix; omitted otherwise.</summary>
|
||||
[JsonProperty("file", NullValueHandling = NullValueHandling.Ignore)] public string File { get; set; }
|
||||
|
||||
/// <summary>Build a <see cref="BuildIssue"/>, reusing <see cref="BuildMessage.Parse"/> for file extraction.</summary>
|
||||
public static BuildIssue From(string content)
|
||||
{
|
||||
var parsed = BuildMessage.Parse("info", content);
|
||||
return new BuildIssue
|
||||
{
|
||||
Message = parsed.Message,
|
||||
File = string.IsNullOrEmpty(parsed.File) ? null : parsed.File
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full <c>build_status</c> result for a finished build (CLI-204) — a JSON-friendly projection of
|
||||
/// <c>UnityEditor.Build.Reporting.BuildReport</c>. Optional collections are omitted when null
|
||||
/// (<see cref="NullValueHandling"/>), so a failed build can carry errors/steps without empty
|
||||
/// file/asset arrays. Transient states (queued/building/idle/dry_run) are reported as small,
|
||||
/// purpose-built payloads, not this type.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
class BuildReportResult
|
||||
{
|
||||
[JsonProperty("status")] public string Status { get; set; }
|
||||
[JsonProperty("buildId")] public string BuildId { get; set; }
|
||||
|
||||
/// <summary>"Succeeded" | "Failed" | "Cancelled" | "Unknown".</summary>
|
||||
[JsonProperty("result")] public string Result { get; set; }
|
||||
|
||||
[JsonProperty("platform")] public string Platform { get; set; }
|
||||
[JsonProperty("outputPath")] public string OutputPath { get; set; }
|
||||
[JsonProperty("totalSizeBytes")] public long TotalSizeBytes { get; set; }
|
||||
[JsonProperty("buildTimeMs")] public long BuildTimeMs { get; set; }
|
||||
|
||||
[JsonProperty("buildStartedAt", NullValueHandling = NullValueHandling.Ignore)] public string BuildStartedAt { get; set; }
|
||||
[JsonProperty("buildEndedAt", NullValueHandling = NullValueHandling.Ignore)] public string BuildEndedAt { get; set; }
|
||||
|
||||
[JsonProperty("totalWarnings")] public int TotalWarnings { get; set; }
|
||||
[JsonProperty("totalErrors")] public int TotalErrors { get; set; }
|
||||
|
||||
[JsonProperty("files", NullValueHandling = NullValueHandling.Ignore)] public List<BuildFileEntry> Files { get; set; }
|
||||
[JsonProperty("packedAssets", NullValueHandling = NullValueHandling.Ignore)] public List<PackedAssetEntry> PackedAssets { get; set; }
|
||||
[JsonProperty("buildSteps", NullValueHandling = NullValueHandling.Ignore)] public List<BuildStepEntry> BuildSteps { get; set; }
|
||||
|
||||
[JsonProperty("errors")] public List<BuildIssue> Errors { get; set; } = new List<BuildIssue>();
|
||||
[JsonProperty("warnings")] public List<BuildIssue> Warnings { get; set; } = new List<BuildIssue>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single build error/warning with best-effort file/line parsed from the message content.
|
||||
/// Retained from the original <c>build</c> stub (CLI-127): it is the shared parser used to extract a
|
||||
/// source location from a raw build-step message and is exercised directly by the test suite.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
class BuildMessage
|
||||
{
|
||||
[JsonProperty("severity")] public string Severity { get; set; }
|
||||
[JsonProperty("file")] public string File { get; set; }
|
||||
[JsonProperty("line")] public int Line { get; set; }
|
||||
[JsonProperty("message")] public string Message { get; set; }
|
||||
|
||||
// Matches the standard compiler/build message prefix "Path/To/File.cs(line,col): rest".
|
||||
static readonly Regex s_LocationPattern = new Regex(@"^(?<file>.*?)\((?<line>\d+),\d+\):\s*(?<rest>.*)$", RegexOptions.Singleline);
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="BuildMessage"/> from a raw build-step message, extracting file/line when
|
||||
/// the content carries the standard "File.cs(line,col): ..." prefix; otherwise the whole content
|
||||
/// is kept as the message with no file/line.
|
||||
/// </summary>
|
||||
public static BuildMessage Parse(string severity, string content)
|
||||
{
|
||||
var msg = new BuildMessage { Severity = severity, Message = content ?? string.Empty };
|
||||
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
var match = s_LocationPattern.Match(content);
|
||||
if (match.Success)
|
||||
{
|
||||
msg.File = match.Groups["file"].Value.Trim();
|
||||
if (int.TryParse(match.Groups["line"].Value, out var line))
|
||||
msg.Line = line;
|
||||
msg.Message = match.Groups["rest"].Value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 37afada24be667e489d7e90a62f2ca1d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
using Unity.Scripting.LifecycleManagement;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Build
|
||||
{
|
||||
/// <summary>
|
||||
/// Switches the active build target (CLI-204). <see cref="BuildPipeline.SwitchActiveBuildTarget"/> is
|
||||
/// synchronous, blocks the main thread, and triggers a full asset reimport + domain reload for the new
|
||||
/// platform — minutes on a large project. So this follows the trigger-then-poll pattern: the command
|
||||
/// validates and queues the switch, returns <c>switching</c> immediately, and an
|
||||
/// <see cref="EditorApplication.update"/> tick performs the switch. The switch is confirm-gated
|
||||
/// (destructive and long-running) and is not part of Unity's Undo.
|
||||
///
|
||||
/// The status is persisted to a Temp file that survives the domain reload the switch causes; on the
|
||||
/// next load <see cref="RecoverInterruptedOperation"/> reconciles a left-over <c>switching</c> record
|
||||
/// against the now-active target. <c>switch_build_target_status</c> reads the file off the main thread,
|
||||
/// so it keeps answering while the switch holds the main thread.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
[NoAutoStaticsCleanup]
|
||||
#endif
|
||||
static class SwitchBuildTargetCommand
|
||||
{
|
||||
const string StatusFile = "Temp/pipeline_switch_target_status.json";
|
||||
|
||||
static readonly object s_Lock = new object();
|
||||
static bool s_Pending;
|
||||
static bool s_Switching;
|
||||
static BuildTarget s_PendingTarget;
|
||||
static BuildTarget s_FromTarget;
|
||||
|
||||
static SwitchBuildTargetCommand()
|
||||
{
|
||||
EditorApplication.update += OnUpdate;
|
||||
RecoverInterruptedOperation();
|
||||
}
|
||||
|
||||
[CliCommand("switch_build_target",
|
||||
"Switch the active build target (destructive, long-running: triggers a full reimport + domain " +
|
||||
"reload). Requires confirm=true. Returns immediately; poll switch_build_target_status.",
|
||||
MainThreadRequired = false,
|
||||
Tags = new[] { "build/targets" })]
|
||||
public static object SwitchBuildTarget(
|
||||
[CliArg("target", "BuildTarget name to switch to (must be installed; see list_build_targets).", Required = true)] string target = "",
|
||||
[CliArg("confirm", "Apply the switch. Without it the call is refused.")] bool confirm = false)
|
||||
{
|
||||
return RunOnMain<object>(() => ValidateAndQueue(target, confirm));
|
||||
}
|
||||
|
||||
static object ValidateAndQueue(string targetArg, bool confirm)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetArg))
|
||||
return new { status = "error", success = false, message = "A 'target' is required." };
|
||||
|
||||
if (!TryParseTarget(targetArg, out var target))
|
||||
return new { status = "error", success = false, message = $"Unknown BuildTarget '{targetArg}'. Use a name from list_build_targets." };
|
||||
|
||||
var group = BuildPipeline.GetBuildTargetGroup(target);
|
||||
if (!BuildPipeline.IsBuildTargetSupported(group, target))
|
||||
return new { status = "error", success = false, message = $"Build target '{target}' is not installed (isInstalled=false in list_build_targets)." };
|
||||
|
||||
var from = EditorUserBuildSettings.activeBuildTarget;
|
||||
if (target == from)
|
||||
return new { status = "completed", success = true, activeBuildTarget = from.ToString(), message = $"Already on build target '{from}'." };
|
||||
|
||||
lock (s_Lock)
|
||||
{
|
||||
if (s_Pending || s_Switching)
|
||||
return new { status = "busy", success = false, message = "A target switch is already queued or in progress. Poll switch_build_target_status." };
|
||||
}
|
||||
|
||||
// Confirm gate. The queue below only sets pending state; the switch runs on the next tick.
|
||||
if (!confirm)
|
||||
return new
|
||||
{
|
||||
status = "error",
|
||||
success = false,
|
||||
message = "Refused: switching the build target triggers a full reimport + domain reload. Pass confirm=true to apply."
|
||||
};
|
||||
|
||||
lock (s_Lock)
|
||||
{
|
||||
s_FromTarget = from;
|
||||
s_PendingTarget = target;
|
||||
s_Pending = true;
|
||||
}
|
||||
WriteStatusJson(new
|
||||
{
|
||||
status = "switching",
|
||||
fromTarget = from.ToString(),
|
||||
toTarget = target.ToString(),
|
||||
startedAt = DateTime.UtcNow.ToString("o")
|
||||
});
|
||||
|
||||
return new { status = "switching", fromTarget = from.ToString(), toTarget = target.ToString() };
|
||||
}
|
||||
|
||||
[CliCommand("switch_build_target_status",
|
||||
"Status of the last target switch: idle | switching | completed (with success + activeBuildTarget).",
|
||||
MainThreadRequired = false,
|
||||
Tags = new[] { "build/targets" })]
|
||||
public static string SwitchBuildTargetStatus()
|
||||
{
|
||||
if (File.Exists(StatusFile))
|
||||
return File.ReadAllText(StatusFile);
|
||||
return "{\"status\":\"idle\"}";
|
||||
}
|
||||
|
||||
/// <summary>Main-thread pump: performs the queued switch. Blocks the main thread until it returns.</summary>
|
||||
static void OnUpdate()
|
||||
{
|
||||
BuildTarget target;
|
||||
BuildTarget from;
|
||||
lock (s_Lock)
|
||||
{
|
||||
if (!s_Pending || s_Switching)
|
||||
return;
|
||||
s_Pending = false;
|
||||
s_Switching = true;
|
||||
target = s_PendingTarget;
|
||||
from = s_FromTarget;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var group = BuildPipeline.GetBuildTargetGroup(target);
|
||||
|
||||
// Synchronous and may trigger a domain reload on success — in which case the code below
|
||||
// never runs and RecoverInterruptedOperation finalizes the status after the reload.
|
||||
// (SwitchActiveBuildTarget moved from BuildPipeline to EditorUserBuildSettings in Unity 6.)
|
||||
var ok = EditorUserBuildSettings.SwitchActiveBuildTarget(group, target);
|
||||
|
||||
if (ok)
|
||||
WriteStatusJson(new
|
||||
{
|
||||
status = "completed",
|
||||
success = true,
|
||||
activeBuildTarget = EditorUserBuildSettings.activeBuildTarget.ToString()
|
||||
});
|
||||
else
|
||||
WriteStatusJson(new
|
||||
{
|
||||
status = "completed",
|
||||
success = false,
|
||||
target = target.ToString(),
|
||||
errors = new[] { new { message = $"Failed to switch build target to '{target}'." } }
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteStatusJson(new
|
||||
{
|
||||
status = "completed",
|
||||
success = false,
|
||||
target = target.ToString(),
|
||||
errors = new[] { new { message = $"Switch to '{target}' threw: {ex.Message}" } }
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (s_Lock) { s_Switching = false; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On load, settle a status file left at "switching" by the domain reload a successful switch
|
||||
/// causes: if the active target now matches the requested one, the switch completed; otherwise it
|
||||
/// did not take effect.
|
||||
/// </summary>
|
||||
static void RecoverInterruptedOperation()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(StatusFile))
|
||||
return;
|
||||
|
||||
var doc = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(StatusFile));
|
||||
if ((string)doc["status"] != "switching")
|
||||
return;
|
||||
|
||||
var toTarget = (string)doc["toTarget"];
|
||||
var active = EditorUserBuildSettings.activeBuildTarget.ToString();
|
||||
|
||||
if (string.Equals(active, toTarget, StringComparison.Ordinal))
|
||||
WriteStatusJson(new { status = "completed", success = true, activeBuildTarget = active });
|
||||
else
|
||||
WriteStatusJson(new
|
||||
{
|
||||
status = "completed",
|
||||
success = false,
|
||||
target = toTarget,
|
||||
errors = new[] { new { message = $"Switch did not take effect; active build target is '{active}'." } }
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[pipeline] switch_build_target status recovery failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------------------
|
||||
|
||||
static bool TryParseTarget(string name, out BuildTarget target)
|
||||
{
|
||||
try
|
||||
{
|
||||
target = (BuildTarget)Enum.Parse(typeof(BuildTarget), name.Trim(), ignoreCase: true);
|
||||
return Enum.IsDefined(typeof(BuildTarget), target);
|
||||
}
|
||||
catch
|
||||
{
|
||||
target = EditorUserBuildSettings.activeBuildTarget;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static T RunOnMain<T>(Func<T> fn)
|
||||
{
|
||||
var dispatcher = PipelineServerStartup.Server?.Dispatcher;
|
||||
if (dispatcher != null && dispatcher.IsInitialized && !dispatcher.IsMainThread())
|
||||
return dispatcher.Invoke(fn);
|
||||
return fn();
|
||||
}
|
||||
|
||||
static void WriteStatusJson(object status)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[SwitchBuildTarget] Failed to write status file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5b3d3b7a26bf4a5c8f5522cbfdd0574b
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 87f4434ff2c6ed6419e6982345ffe0ef
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,428 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Capture
|
||||
{
|
||||
/// <summary>
|
||||
/// Visual-feedback commands (CLI-199): render a camera, the Scene View, or the composited game view
|
||||
/// screen to a PNG and return it base64-encoded, so an agent can "see" the editor without a display.
|
||||
/// The screen source (AUTHAPI-10) also captures Screen Space - Overlay UI (HUDs) that never renders
|
||||
/// through a camera. Optionally writes the PNG under the project (sandboxed via
|
||||
/// <see cref="ProjectPaths"/>) and refreshes the AssetDatabase.
|
||||
///
|
||||
/// Captures require a GPU; in batchmode/headless the device type is
|
||||
/// <see cref="GraphicsDeviceType.Null"/> and the commands throw so callers get a clear message
|
||||
/// instead of an empty image.
|
||||
/// </summary>
|
||||
static class CaptureCommands
|
||||
{
|
||||
private const int MaxDimension = 4096;
|
||||
|
||||
[CliCommand("capture_game_view", "Render the game view to a PNG. source=camera (default) renders a camera and misses Screen Space - Overlay UI; source=screen captures the composited backbuffer incl. overlay canvases (Play Mode only). Returns it inline as base64, unless save_path is set (path-only result; pass include_inline_image=true to get both).", Tags = new[] { "capture" })]
|
||||
public static CaptureResult CaptureGameView(
|
||||
[CliArg("width", "Output width in px (default 1280; capped 4096).")] int width = 1280,
|
||||
[CliArg("height", "Output height in px (default 720; capped 4096).")] int height = 720,
|
||||
[CliArg("camera", "Optional camera name (source=camera only); defaults to Camera.main, else the first enabled camera.")] string camera = null,
|
||||
[CliArg("save_path", "Optional project-relative path to write the PNG (e.g. Screenshots/foo.png). When set, the result omits the inline base64 image unless include_inline_image=true.")] string savePath = null,
|
||||
[CliArg("include_inline_image", "Also return the image inline as base64 when save_path is set (default false: path-only result). Only meaningful together with save_path.")] bool includeInlineImage = false,
|
||||
[CliArg("max_resolution", "Cap on the inline image's longest edge (e.g. 512). Only applies when an inline image is returned (no save_path, or save_path + include_inline_image=true); the save_path file keeps the requested resolution.")] int maxResolution = 0,
|
||||
[CliArg("source", "What to capture: 'camera' (default) renders a camera (misses Screen Space - Overlay UI); 'screen' captures the composited game view incl. overlay canvases (HUDs), Play Mode only.")] string source = "camera")
|
||||
{
|
||||
var src = (source ?? "camera").Trim().ToLowerInvariant();
|
||||
switch (src)
|
||||
{
|
||||
case "camera":
|
||||
{
|
||||
GuardHasGpu();
|
||||
|
||||
var cam = ResolveCamera(camera);
|
||||
if (cam == null)
|
||||
throw new ArgumentException("No camera found to capture.");
|
||||
|
||||
return Capture((w, h) => EncodeCameraToPng(cam, w, h), $"camera:{cam.name}",
|
||||
width, height, savePath, includeInlineImage, maxResolution);
|
||||
}
|
||||
case "screen":
|
||||
{
|
||||
// Overlay UI only composites to the backbuffer at runtime, so screen capture is a
|
||||
// Play-Mode operation; guard before touching the GPU so the message is deterministic.
|
||||
if (!Application.isPlaying)
|
||||
throw new InvalidOperationException(
|
||||
"capture_game_view with source=\"screen\" requires Play Mode: Screen Space - Overlay UI only composites to the backbuffer at runtime. Use source=\"camera\" in Edit Mode.");
|
||||
|
||||
GuardHasGpu();
|
||||
|
||||
return Capture(EncodeScreenToPng, "screen",
|
||||
width, height, savePath, includeInlineImage, maxResolution);
|
||||
}
|
||||
default:
|
||||
throw new ArgumentException($"Unknown source '{source}'. Use 'camera' or 'screen'.");
|
||||
}
|
||||
}
|
||||
|
||||
[CliCommand("capture_scene_view", "Render the active Scene View to a PNG. Returns it inline as base64, unless save_path is set (path-only result; pass include_inline_image=true to get both).", Tags = new[] { "capture" })]
|
||||
public static CaptureResult CaptureSceneView(
|
||||
[CliArg("width", "Output width in px (default 1280; capped 4096).")] int width = 1280,
|
||||
[CliArg("height", "Output height in px (default 720; capped 4096).")] int height = 720,
|
||||
[CliArg("save_path", "Optional project-relative path to write the PNG (e.g. Screenshots/foo.png). When set, the result omits the inline base64 image unless include_inline_image=true.")] string savePath = null,
|
||||
[CliArg("include_inline_image", "Also return the image inline as base64 when save_path is set (default false: path-only result). Only meaningful together with save_path.")] bool includeInlineImage = false,
|
||||
[CliArg("max_resolution", "Cap on the inline image's longest edge (e.g. 512). Only applies when an inline image is returned (no save_path, or save_path + include_inline_image=true); the save_path file keeps the requested resolution.")] int maxResolution = 0)
|
||||
{
|
||||
GuardHasGpu();
|
||||
|
||||
var sv = SceneView.lastActiveSceneView;
|
||||
if (sv == null || sv.camera == null)
|
||||
throw new ArgumentException("No active Scene View to capture.");
|
||||
|
||||
var cam = sv.camera;
|
||||
return Capture((w, h) => EncodeCameraToPng(cam, w, h), "sceneView",
|
||||
width, height, savePath, includeInlineImage, maxResolution);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared capture core. Renders at the requested (clamped) size via <paramref name="renderPng"/>
|
||||
/// and writes the file when <paramref name="savePath"/> is set. The image is inlined as base64
|
||||
/// only when there is no file, or on <paramref name="includeInlineImage"/> — a save_path result
|
||||
/// is otherwise path-only, so agent tool results stay small (AUTHAPI-8).
|
||||
/// <paramref name="maxResolution"/> caps the inline image's longest edge (no-op when no inline
|
||||
/// image is returned); the saved file keeps the requested resolution. <paramref name="renderPng"/>
|
||||
/// is the source-specific encoder — camera render (source=camera / scene view) or screen
|
||||
/// backbuffer (source=screen, incl. overlay canvases, AUTHAPI-10).
|
||||
/// </summary>
|
||||
private static CaptureResult Capture(Func<int, int, byte[]> renderPng, string source,
|
||||
int width, int height, string savePath, bool includeInlineImage, int maxResolution)
|
||||
{
|
||||
var w = Mathf.Clamp(width, 1, MaxDimension);
|
||||
var h = Mathf.Clamp(height, 1, MaxDimension);
|
||||
|
||||
var wantsFile = !string.IsNullOrEmpty(savePath);
|
||||
var wantsInline = !wantsFile || includeInlineImage;
|
||||
|
||||
// Without a file the inline image is the only artifact: the cap applies to the render itself.
|
||||
if (!wantsFile && maxResolution > 0)
|
||||
(w, h) = ClampToLongEdge(w, h, maxResolution);
|
||||
|
||||
var png = renderPng(w, h);
|
||||
var savedPath = WriteIfRequested(png, savePath);
|
||||
|
||||
string base64 = null;
|
||||
int? inlineW = null, inlineH = null;
|
||||
if (wantsInline)
|
||||
{
|
||||
var inlinePng = png;
|
||||
if (wantsFile && maxResolution > 0 && maxResolution < Mathf.Max(w, h))
|
||||
{
|
||||
// Re-render small for the inline copy; the file already has the full resolution.
|
||||
var (iw, ih) = ClampToLongEdge(w, h, maxResolution);
|
||||
inlinePng = renderPng(iw, ih);
|
||||
inlineW = iw;
|
||||
inlineH = ih;
|
||||
}
|
||||
base64 = Convert.ToBase64String(inlinePng);
|
||||
}
|
||||
|
||||
return new CaptureResult
|
||||
{
|
||||
Width = w,
|
||||
Height = h,
|
||||
Encoding = "png",
|
||||
Base64 = base64,
|
||||
Bytes = png.Length,
|
||||
Source = source,
|
||||
SavedPath = savedPath,
|
||||
InlineWidth = inlineW,
|
||||
InlineHeight = inlineH
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Scale (w, h) down so the longest edge is at most <paramref name="maxEdge"/>, preserving aspect.</summary>
|
||||
private static (int w, int h) ClampToLongEdge(int w, int h, int maxEdge)
|
||||
{
|
||||
var longest = Mathf.Max(w, h);
|
||||
if (maxEdge <= 0 || longest <= maxEdge)
|
||||
return (w, h);
|
||||
|
||||
var scale = (float)maxEdge / longest;
|
||||
return (Mathf.Max(1, Mathf.RoundToInt(w * scale)), Mathf.Max(1, Mathf.RoundToInt(h * scale)));
|
||||
}
|
||||
|
||||
/// <summary>Throw when no GPU is available (batchmode/headless), where a render would be blank.</summary>
|
||||
private static void GuardHasGpu()
|
||||
{
|
||||
if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null)
|
||||
throw new InvalidOperationException("No GPU available (batchmode/headless); cannot capture.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the camera to capture: by name (if provided), else <see cref="Camera.main"/>,
|
||||
/// else the first enabled camera. Returns null when nothing matches.
|
||||
/// </summary>
|
||||
private static Camera ResolveCamera(string name)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
{
|
||||
// Camera.allCameras only includes enabled cameras; also scan all loaded cameras so a
|
||||
// disabled-but-named camera can still be targeted explicitly.
|
||||
var byName = Camera.allCameras.FirstOrDefault(c => c.name == name)
|
||||
?? PipelineUtils.FindObjectsByType<Camera>().FirstOrDefault(c => c.name == name);
|
||||
return byName;
|
||||
}
|
||||
|
||||
if (Camera.main != null)
|
||||
return Camera.main;
|
||||
|
||||
return Camera.allCameras.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render <paramref name="cam"/> off-screen into a temporary RenderTexture and encode the
|
||||
/// result to PNG bytes. The camera's target texture and the active RenderTexture are restored
|
||||
/// in a finally block so capturing never leaves global render state dirty.
|
||||
/// </summary>
|
||||
private static byte[] EncodeCameraToPng(Camera cam, int w, int h)
|
||||
{
|
||||
var rt = RenderTexture.GetTemporary(w, h, 24);
|
||||
var prevTarget = cam.targetTexture;
|
||||
var prevActive = RenderTexture.active;
|
||||
try
|
||||
{
|
||||
cam.targetTexture = rt;
|
||||
cam.Render();
|
||||
RenderTexture.active = rt;
|
||||
|
||||
var tex = new Texture2D(w, h, TextureFormat.RGB24, false);
|
||||
try
|
||||
{
|
||||
tex.ReadPixels(new Rect(0, 0, w, h), 0, 0);
|
||||
tex.Apply();
|
||||
return ImageConversion.EncodeToPNG(tex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(tex);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
cam.targetTexture = prevTarget;
|
||||
RenderTexture.active = prevActive;
|
||||
RenderTexture.ReleaseTemporary(rt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture the composited game view — including Screen Space - Overlay canvases and other
|
||||
/// screen-space UI that never render through a camera (AUTHAPI-10) — and encode it to PNG,
|
||||
/// scaled to the requested size.
|
||||
///
|
||||
/// The primary source is the game view's own render target (see
|
||||
/// <see cref="GetGameViewTargetTexture"/>): the most recently rendered game frame at its true
|
||||
/// pixel resolution, independent of window focus and OS display scaling. The old
|
||||
/// <see cref="ScreenCapture.CaptureScreenshotAsTexture()"/> path had two field-reported bugs:
|
||||
/// it reads a Screen-sized rect in GUI <em>points</em> from a backbuffer sized in physical
|
||||
/// <em>pixels</em>, cropping the frame on scaled displays (e.g. 150% OS scaling), and its
|
||||
/// result texture round-trips gamma twice in Linear projects, washing out every capture.
|
||||
/// </summary>
|
||||
private static byte[] EncodeScreenToPng(int w, int h)
|
||||
{
|
||||
var outRt = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32);
|
||||
var prevActive = RenderTexture.active;
|
||||
RenderTexture fallbackRt = null;
|
||||
try
|
||||
{
|
||||
var source = GetGameViewTargetTexture();
|
||||
if (source == null)
|
||||
{
|
||||
// Fallback when the internal PlayModeView API is unavailable: ask ScreenCapture for
|
||||
// the composited view. Less deterministic — when the editor runs unfocused it can
|
||||
// capture whichever editor view renders next — but keeps the command functional.
|
||||
var (screenW, screenH) = GetGameViewTargetSize();
|
||||
fallbackRt = RenderTexture.GetTemporary(screenW, screenH, 0, RenderTextureFormat.ARGB32);
|
||||
|
||||
// GetTemporary pools RTs: clear before capturing so a skipped/partial capture can
|
||||
// never leak a stale frame from an earlier capture.
|
||||
RenderTexture.active = fallbackRt;
|
||||
GL.Clear(true, true, Color.black);
|
||||
|
||||
// Unbind before capturing: CaptureScreenshotIntoRenderTexture silently writes
|
||||
// nothing while its destination is the currently-active RenderTexture (verified
|
||||
// empirically — the capture comes back all black).
|
||||
RenderTexture.active = prevActive;
|
||||
ScreenCapture.CaptureScreenshotIntoRenderTexture(fallbackRt);
|
||||
source = fallbackRt;
|
||||
}
|
||||
|
||||
// Scale to the requested output size. Default read/write semantics keep the blit
|
||||
// conversion-neutral, so the display-ready values reach the PNG unchanged (no double
|
||||
// gamma). The source holds GPU-UV-oriented content: on APIs where UV(0,0) is the top
|
||||
// (D3D, Metal, Vulkan) it arrives vertically flipped, so un-flip in the blit.
|
||||
if (SystemInfo.graphicsUVStartsAtTop)
|
||||
Graphics.Blit(source, outRt, new Vector2(1f, -1f), new Vector2(0f, 1f));
|
||||
else
|
||||
Graphics.Blit(source, outRt);
|
||||
RenderTexture.active = outRt;
|
||||
|
||||
var tex = new Texture2D(w, h, TextureFormat.RGB24, false);
|
||||
try
|
||||
{
|
||||
tex.ReadPixels(new Rect(0, 0, w, h), 0, 0);
|
||||
tex.Apply();
|
||||
return ImageConversion.EncodeToPNG(tex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(tex);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderTexture.active = prevActive;
|
||||
RenderTexture.ReleaseTemporary(outRt);
|
||||
if (fallbackRt != null)
|
||||
RenderTexture.ReleaseTemporary(fallbackRt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The game view's own render target (via the internal <c>PlayModeView</c> API), or null when
|
||||
/// unavailable. This is the most reliable "what the game view shows" source: the
|
||||
/// <c>ScreenCapture</c> APIs capture whichever editor view happens to render, which is
|
||||
/// focus-dependent — an unfocused/background editor (the normal state for an agent-driven
|
||||
/// editor) can hand back an editor panel (Hierarchy, Inspector) instead of the game. The
|
||||
/// target texture holds the game view's most recently rendered frame — overlay canvases
|
||||
/// included — at its true pixel resolution, independent of focus and display scaling.
|
||||
/// </summary>
|
||||
private static RenderTexture GetGameViewTargetTexture()
|
||||
{
|
||||
try
|
||||
{
|
||||
var playModeView = Type.GetType("UnityEditor.PlayModeView,UnityEditor");
|
||||
var getMain = playModeView?.GetMethod("GetMainPlayModeView",
|
||||
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
|
||||
var view = getMain?.Invoke(null, null);
|
||||
if (view == null)
|
||||
return null;
|
||||
|
||||
var field = playModeView.GetField("m_TargetTexture",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
|
||||
var rt = field?.GetValue(view) as RenderTexture;
|
||||
return rt != null && rt.IsCreated() ? rt : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Internal API changed — callers fall back to the ScreenCapture path.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Size of the game view's actual render target, in pixels. <c>Screen.width/height</c> in
|
||||
/// editor main-thread code report the CURRENT GUI view — which is focus-dependent and can be
|
||||
/// the main editor window rather than the game view (observed as black bars / truncated
|
||||
/// captures). The internal PlayModeView API reports the true target size regardless of focus
|
||||
/// and display scaling; fall back to <c>Screen</c> when the internal API is unavailable.
|
||||
/// </summary>
|
||||
private static (int w, int h) GetGameViewTargetSize()
|
||||
{
|
||||
try
|
||||
{
|
||||
var playModeView = Type.GetType("UnityEditor.PlayModeView,UnityEditor");
|
||||
var method = playModeView?.GetMethod("GetMainPlayModeViewTargetSize",
|
||||
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
|
||||
if (method != null)
|
||||
{
|
||||
var size = (Vector2)method.Invoke(null, null);
|
||||
if (size.x >= 1f && size.y >= 1f)
|
||||
return ((int)size.x, (int)size.y);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Internal API changed/unavailable — the Screen fallback below still works whenever
|
||||
// the game view is the current context.
|
||||
}
|
||||
|
||||
return (Screen.width, Screen.height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When <paramref name="savePath"/> is set, validate it through the project-path sandbox,
|
||||
/// write the PNG to its absolute filesystem location, refresh the AssetDatabase, and return
|
||||
/// the resolved project-relative asset path. Returns null when no path was requested.
|
||||
/// </summary>
|
||||
private static string WriteIfRequested(byte[] png, string savePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(savePath))
|
||||
return null;
|
||||
|
||||
var resolved = ProjectPaths.Resolve(savePath, out var err);
|
||||
if (resolved == null)
|
||||
throw new ArgumentException(err);
|
||||
|
||||
// ProjectPaths.Resolve returns a project-relative path; combine it with the project root
|
||||
// (the folder that contains Assets/) to get the absolute file to write.
|
||||
var absolute = Path.Combine(ProjectPaths.ProjectRoot, resolved);
|
||||
var directory = Path.GetDirectoryName(absolute);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
File.WriteAllBytes(absolute, png);
|
||||
AssetDatabase.Refresh();
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a capture command: the rendered dimensions, the source, the project-relative path
|
||||
/// the PNG was written to (null when not saved), and the base64 payload — omitted from the JSON
|
||||
/// when a save_path result is path-only (AUTHAPI-8).
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
class CaptureResult
|
||||
{
|
||||
/// <summary>Rendered width in pixels (after clamping).</summary>
|
||||
[JsonProperty("width")]
|
||||
public int Width { get; set; }
|
||||
|
||||
/// <summary>Rendered height in pixels (after clamping).</summary>
|
||||
[JsonProperty("height")]
|
||||
public int Height { get; set; }
|
||||
|
||||
/// <summary>Image encoding; always "png".</summary>
|
||||
[JsonProperty("encoding")]
|
||||
public string Encoding { get; set; }
|
||||
|
||||
/// <summary>Base64-encoded PNG bytes; null (omitted) when save_path is set without include_inline_image.</summary>
|
||||
[JsonProperty("base64", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string Base64 { get; set; }
|
||||
|
||||
/// <summary>Length of the raw PNG byte array.</summary>
|
||||
[JsonProperty("bytes")]
|
||||
public int Bytes { get; set; }
|
||||
|
||||
/// <summary>What was captured, e.g. "camera:Main Camera" or "sceneView".</summary>
|
||||
[JsonProperty("source")]
|
||||
public string Source { get; set; }
|
||||
|
||||
/// <summary>Project-relative path the PNG was also written to, or null.</summary>
|
||||
[JsonProperty("savedPath")]
|
||||
public string SavedPath { get; set; }
|
||||
|
||||
/// <summary>Inline image width when max_resolution downscaled it below the saved file's; omitted otherwise.</summary>
|
||||
[JsonProperty("inlineWidth", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public int? InlineWidth { get; set; }
|
||||
|
||||
/// <summary>Inline image height when max_resolution downscaled it below the saved file's; omitted otherwise.</summary>
|
||||
[JsonProperty("inlineHeight", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public int? InlineHeight { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3247a78c799021c4094dd3f7e3717aeb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,78 @@
|
||||
#if UNITY_6000_7_OR_NEWER
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Runtime.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Captures a UI Toolkit <see cref="VisualElement"/> from an <see cref="EditorWindow"/> to a PNG
|
||||
/// and returns it base64-encoded (and writes it to disk). The element is located by a USS-like
|
||||
/// selector within the window's <see cref="EditorWindow.rootVisualElement"/>.
|
||||
///
|
||||
/// Selection, capture, and encoding are shared with the runtime command via
|
||||
/// <see cref="VisualElementCaptureSupport"/>; this command only resolves the target window.
|
||||
/// </summary>
|
||||
static class CaptureEditorElementCommand
|
||||
{
|
||||
[CliCommand("capture_editor_element",
|
||||
"Capture a UI Toolkit VisualElement (by selector) from an EditorWindow to a PNG; returns path + base64.",
|
||||
MainThreadRequired = true,
|
||||
Tags = new[] { "capture", "ui" })]
|
||||
public static CaptureElementResponse CaptureEditorElement(
|
||||
[CliArg("window", "EditorWindow type name (e.g. InspectorWindow) or window title to capture from.", Required = true)] string window = "",
|
||||
[CliArg("selector", "Element selector: '#name', '.class', a type name (e.g. Button), descendant (space) / child ('>') chains, optional pseudo-states (:checked,:hover,:focus,:active,:enabled,:disabled,:not(...)).", Required = true)] string selector = "",
|
||||
[CliArg("output", "Output PNG path (absolute, or relative to the project root). Defaults to a timestamped file under <project>/Temp/pipeline-screenshots/.")] string output = "")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(window))
|
||||
return CaptureElementResponse.Fail("A 'window' (EditorWindow type name or title) is required.");
|
||||
if (string.IsNullOrWhiteSpace(selector))
|
||||
return CaptureElementResponse.Fail("A 'selector' is required.");
|
||||
|
||||
var target = ResolveWindow(window);
|
||||
if (target == null)
|
||||
return CaptureElementResponse.Fail(
|
||||
$"No open EditorWindow matched '{window}' (by type name or title). Open the window first.");
|
||||
|
||||
var root = target.rootVisualElement;
|
||||
if (root == null)
|
||||
return CaptureElementResponse.Fail($"EditorWindow '{window}' has no rootVisualElement.");
|
||||
|
||||
var element = VisualElementCaptureSupport.ResolveElement(root, selector);
|
||||
if (element == null)
|
||||
return CaptureElementResponse.Fail(
|
||||
$"No element matched selector '{selector}' in window '{window}'.");
|
||||
|
||||
var projectRoot = Path.GetDirectoryName(Application.dataPath);
|
||||
var defaultDir = Path.Combine(projectRoot, "Temp", "pipeline-screenshots");
|
||||
return VisualElementCaptureSupport.CaptureAndRespond(
|
||||
element, selector, $"window:{window}", output,
|
||||
relativeBaseDir: projectRoot, defaultDir: defaultDir, prefix: "element");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find an open EditorWindow whose runtime type name or title matches <paramref name="window"/>.
|
||||
/// Uses <c>Resources.FindObjectsOfTypeAll</c> so hidden/utility windows are included too, but
|
||||
/// prefers a match whose root is actually attached to a panel — <c>FindObjectsOfTypeAll</c> can
|
||||
/// also return stale/backup window instances whose <c>rootVisualElement</c> has no panel (and
|
||||
/// therefore cannot be captured).
|
||||
/// </summary>
|
||||
static EditorWindow ResolveWindow(string window)
|
||||
{
|
||||
var windows = Resources.FindObjectsOfTypeAll<EditorWindow>();
|
||||
|
||||
bool Matches(EditorWindow w) =>
|
||||
w.GetType().Name == window ||
|
||||
(w.titleContent != null && w.titleContent.text == window);
|
||||
|
||||
return windows.FirstOrDefault(w => Matches(w)
|
||||
&& w.rootVisualElement != null && w.rootVisualElement.panel != null)
|
||||
?? windows.FirstOrDefault(Matches);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d09a03ff1d920b24080d933c3191e192
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Command to get detailed Editor status information.
|
||||
/// Returns rich Editor state including compilation, play mode, and project details.
|
||||
/// Accessible via "unity request editor_status" and "/api/editor_status" endpoint.
|
||||
/// </summary>
|
||||
static class EditorStatusCommand
|
||||
{
|
||||
[CliCommand("editor_status", "Get detailed Unity Editor status and state information", Tags = new[] { "editor" })]
|
||||
public static StatusResponse GetEditorStatus()
|
||||
{
|
||||
return new StatusResponse
|
||||
{
|
||||
Status = GetOverallStatus(),
|
||||
Compiling = EditorApplication.isCompiling,
|
||||
DomainReloadInProgress = EditorApplication.isUpdating,
|
||||
PlayMode = GetPlayModeStatus(),
|
||||
LastHeartbeat = DateTime.UtcNow,
|
||||
ProjectPath = Path.GetDirectoryName(Application.dataPath),
|
||||
UnityVersion = Application.unityVersion
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get overall Editor status based on current state.
|
||||
/// </summary>
|
||||
private static string GetOverallStatus()
|
||||
{
|
||||
if (EditorApplication.isCompiling)
|
||||
return "compiling";
|
||||
|
||||
if (EditorApplication.isUpdating)
|
||||
return "reloading";
|
||||
|
||||
if (EditorApplication.isPlaying || EditorApplication.isPaused)
|
||||
return "playing";
|
||||
|
||||
return "ready";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current play mode status.
|
||||
/// </summary>
|
||||
private static string GetPlayModeStatus()
|
||||
{
|
||||
if (EditorApplication.isPaused)
|
||||
return "paused";
|
||||
|
||||
if (EditorApplication.isPlaying)
|
||||
return "playing";
|
||||
|
||||
return "stopped";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 953fb97ec3dd48e4aa2ed9607c412f44
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
using Unity.Scripting.LifecycleManagement;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Brings the Unity Editor application to the foreground (and restores it if minimized) by
|
||||
/// focusing the main window's HostView.
|
||||
///
|
||||
/// ContainerWindow / HostView / GUIView are internal, so this uses reflection. GUIView.Focus()
|
||||
/// maps to the native MonoGUIView::Focus, which activates the OS window. Useful for headless
|
||||
/// automation: the editor throttles/defers some work (notably script compilation) while
|
||||
/// unfocused, so a client can call this to bring it forward first.
|
||||
/// </summary>
|
||||
#if UNITY_6000_5_OR_NEWER
|
||||
[NoAutoStaticsCleanup]
|
||||
#endif
|
||||
static class FocusEditorCommand
|
||||
{
|
||||
const BindingFlags k_All = BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
|
||||
static Type s_ContainerWindowType;
|
||||
static Type s_HostViewType;
|
||||
static MethodInfo s_IsMainWindow;
|
||||
static MethodInfo s_GuiViewFocus;
|
||||
static bool s_Resolved;
|
||||
|
||||
[CliCommand("editor_focus", "Bring the Unity Editor window to the foreground", MainThreadRequired = true, Tags = new[] { "editor" })]
|
||||
public static string FocusEditor()
|
||||
{
|
||||
if (!ResolveReflection(out var error))
|
||||
return error;
|
||||
|
||||
try
|
||||
{
|
||||
object mainWindow = null;
|
||||
foreach (var w in Resources.FindObjectsOfTypeAll(s_ContainerWindowType))
|
||||
{
|
||||
if ((bool)s_IsMainWindow.Invoke(w, null)) { mainWindow = w; break; }
|
||||
}
|
||||
if (mainWindow == null)
|
||||
return "Error: main editor window not found";
|
||||
|
||||
var rootView = GetRootView(mainWindow);
|
||||
if (rootView == null)
|
||||
return "Error: main window has no root view";
|
||||
|
||||
var host = FindHostView(rootView);
|
||||
if (host == null)
|
||||
return "Error: no HostView found in main window";
|
||||
|
||||
s_GuiViewFocus.Invoke(host, null);
|
||||
return $"Editor focused via {host.GetType().Name}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Error: failed to focus editor: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
static bool ResolveReflection(out string error)
|
||||
{
|
||||
error = null;
|
||||
if (s_Resolved) return true;
|
||||
|
||||
var asm = typeof(EditorWindow).Assembly;
|
||||
s_ContainerWindowType = asm.GetType("UnityEditor.ContainerWindow");
|
||||
s_HostViewType = asm.GetType("UnityEditor.HostView");
|
||||
var guiViewType = asm.GetType("UnityEditor.GUIView");
|
||||
|
||||
if (s_ContainerWindowType == null || s_HostViewType == null || guiViewType == null)
|
||||
{
|
||||
error = "Error: internal editor window types not found (Unity version mismatch?)";
|
||||
return false;
|
||||
}
|
||||
|
||||
s_IsMainWindow = s_ContainerWindowType.GetMethod("IsMainWindow", k_All);
|
||||
s_GuiViewFocus = guiViewType.GetMethod("Focus", k_All, null, Type.EmptyTypes, null);
|
||||
|
||||
if (s_IsMainWindow == null || s_GuiViewFocus == null)
|
||||
{
|
||||
error = "Error: ContainerWindow.IsMainWindow or GUIView.Focus not found (internal API changed?)";
|
||||
return false;
|
||||
}
|
||||
|
||||
s_Resolved = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
static object GetRootView(object mainWindow)
|
||||
{
|
||||
var prop = s_ContainerWindowType.GetProperty("rootView", k_All);
|
||||
if (prop != null)
|
||||
return prop.GetValue(mainWindow);
|
||||
return s_ContainerWindowType.GetField("m_RootView", k_All)?.GetValue(mainWindow);
|
||||
}
|
||||
|
||||
static object FindHostView(object rootView)
|
||||
{
|
||||
if (s_HostViewType.IsInstanceOfType(rootView))
|
||||
return rootView;
|
||||
|
||||
if (!(rootView.GetType().GetProperty("allChildren", k_All)?.GetValue(rootView) is Array allChildren))
|
||||
return null;
|
||||
|
||||
// Prefer a DockArea (an actual docked panel) over the Toolbar, but any HostView works
|
||||
// to bring the main window — and thus the application — to the foreground.
|
||||
object firstHost = null;
|
||||
foreach (var c in allChildren)
|
||||
{
|
||||
if (c == null || !s_HostViewType.IsInstanceOfType(c)) continue;
|
||||
if (firstHost == null) firstHost = c;
|
||||
if (c.GetType().Name == "DockArea") return c;
|
||||
}
|
||||
return firstHost;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f146c518ed01a1b4a9be8a546cdfbde5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c2281ed89e8a4db9a14ec523905221f9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.GameObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Component authoring commands (CLI-192): add/remove a component on a GameObject and get/set its
|
||||
/// serialized properties.
|
||||
///
|
||||
/// WHY property get/set goes exclusively through <see cref="SerializedObject"/> /
|
||||
/// <see cref="SerializedProperty"/>: it is the only mutation path that dirties the object, records
|
||||
/// prefab overrides, and registers a single Undo step (via <see cref="Undo.RecordObject"/> +
|
||||
/// <see cref="SerializedObject.ApplyModifiedProperties"/>) the way the Inspector does. Direct
|
||||
/// reflection on backing fields would silently desync the Editor. Component types are resolved by
|
||||
/// name across all loaded assemblies (<see cref="TypeResolver"/>) so agents can use short or
|
||||
/// fully-qualified names.
|
||||
/// </summary>
|
||||
static class ComponentCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Add a component (resolved by type name) to a GameObject. Registered with
|
||||
/// <see cref="Undo.AddComponent{T}(GameObject)"/> via the non-generic overload so the addition
|
||||
/// is reversible. Returns the new component's identity so its properties can be set next.
|
||||
/// </summary>
|
||||
[CliCommand("add_component", "Add a component (by type name) to a GameObject.", Tags = new[] { "gameobjects/components" })]
|
||||
public static AuthoringResult AddComponent(
|
||||
[CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target,
|
||||
[CliArg("type", "Component type name (e.g. 'Rigidbody' or 'UnityEngine.Camera').", Required = true)] string type)
|
||||
{
|
||||
var go = GameObjectCommands.ResolveGameObject(target, "target");
|
||||
var componentType = TypeResolver.ResolveComponentType(type);
|
||||
if (componentType == null)
|
||||
throw new ArgumentException($"Could not resolve component type '{type}'.");
|
||||
|
||||
using (new AuthoringUndoScope("Add Component"))
|
||||
{
|
||||
var component = Undo.AddComponent(go, componentType);
|
||||
if (component == null)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to add component '{componentType.Name}' to '{go.name}' (it may be disallowed on this GameObject).");
|
||||
|
||||
GameObjectCommands.MarkDirty(go);
|
||||
return ObjectResolver.Describe(component);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a component from a GameObject. The component is addressed directly by handle (an
|
||||
/// <see cref="ObjectRef"/> resolving to a Component), or by GameObject handle plus a type name.
|
||||
/// Uses <see cref="Undo.DestroyObjectImmediate"/> so the removal is reversible.
|
||||
/// </summary>
|
||||
[CliCommand("remove_component",
|
||||
"Remove a component from a GameObject. Provide either a component handle (target) or a GameObject handle (target) plus a type name.",
|
||||
Tags = new[] { "gameobjects/components" })]
|
||||
public static AuthoringResult RemoveComponent(
|
||||
[CliArg("target", "Handle of the component to remove, OR of the GameObject when 'type' is given.", Required = true)] ObjectRef target,
|
||||
[CliArg("type", "Component type name to remove from the target GameObject (omit when 'target' already points at a component).")] string type = null)
|
||||
{
|
||||
var component = ResolveComponent(target, type);
|
||||
var go = component.gameObject;
|
||||
var described = ObjectResolver.Describe(component);
|
||||
|
||||
using (new AuthoringUndoScope("Remove Component"))
|
||||
{
|
||||
Undo.DestroyObjectImmediate(component);
|
||||
GameObjectCommands.MarkDirty(go);
|
||||
}
|
||||
|
||||
return described;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the serialized properties of a component as a JSON map. Iterates the visible serialized
|
||||
/// surface (skipping the script reference) and converts each property with
|
||||
/// <see cref="SerializedPropertyConverter.Read"/>.
|
||||
/// </summary>
|
||||
[CliCommand("get_component_properties",
|
||||
"Get a component's serialized properties as a JSON map. Address the component by handle, or by GameObject handle + type.",
|
||||
Tags = new[] { "gameobjects/components" })]
|
||||
public static ComponentPropertiesResult GetComponentProperties(
|
||||
[CliArg("target", "Handle of the component, OR of the GameObject when 'type' is given.", Required = true)] ObjectRef target,
|
||||
[CliArg("type", "Component type name on the target GameObject (omit when 'target' is a component handle).")] string type = null)
|
||||
{
|
||||
var component = ResolveComponent(target, type);
|
||||
var so = new SerializedObject(component);
|
||||
|
||||
var properties = new Dictionary<string, JToken>();
|
||||
var iterator = so.GetIterator();
|
||||
var enterChildren = true;
|
||||
while (iterator.NextVisible(enterChildren))
|
||||
{
|
||||
enterChildren = false; // only iterate top-level visible properties
|
||||
if (iterator.name == "m_Script")
|
||||
continue;
|
||||
|
||||
try
|
||||
{
|
||||
properties[iterator.name] = SerializedPropertyConverter.Read(iterator.Copy());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
properties[iterator.name] = new JValue($"<error:{ex.Message}>");
|
||||
}
|
||||
}
|
||||
|
||||
return new ComponentPropertiesResult
|
||||
{
|
||||
Component = ObjectResolver.Describe(component),
|
||||
Properties = properties
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set one or more serialized properties on a component. Each entry in <paramref name="properties"/>
|
||||
/// maps a property path (e.g. "m_Mass" or "myField") to a JSON value. The whole batch is one
|
||||
/// Undo step: <see cref="Undo.RecordObject"/> snapshots the component, every property is written
|
||||
/// through <see cref="SerializedPropertyConverter.Write"/>, and
|
||||
/// <see cref="SerializedObject.ApplyModifiedProperties"/> commits + dirties as one operation.
|
||||
/// An unknown property name fails the whole batch with a clear error (no partial apply).
|
||||
/// </summary>
|
||||
[CliCommand("set_component_properties",
|
||||
"Set serialized properties on a component (one Undo step). 'properties' maps property name -> value; object references accept an ObjectRef handle.",
|
||||
Tags = new[] { "gameobjects/components" })]
|
||||
public static ComponentPropertiesResult SetComponentProperties(
|
||||
[CliArg("target", "Handle of the component, OR of the GameObject when 'type' is given.", Required = true)] ObjectRef target,
|
||||
[CliArg("properties", "Map of serialized property name to value. Vectors/colors are arrays; object refs are handle objects.", Required = true)] JObject properties,
|
||||
[CliArg("type", "Component type name on the target GameObject (omit when 'target' is a component handle).")] string type = null)
|
||||
{
|
||||
if (properties == null || properties.Count == 0)
|
||||
throw new ArgumentException("'properties' must contain at least one property to set.");
|
||||
|
||||
var component = ResolveComponent(target, type);
|
||||
|
||||
using (new AuthoringUndoScope("Set Component Properties"))
|
||||
{
|
||||
Undo.RecordObject(component, "Set Component Properties");
|
||||
var so = new SerializedObject(component);
|
||||
|
||||
foreach (var pair in properties)
|
||||
{
|
||||
var property = so.FindProperty(pair.Key);
|
||||
if (property == null)
|
||||
throw new ArgumentException(
|
||||
$"Component '{component.GetType().Name}' has no serialized property '{pair.Key}'.");
|
||||
|
||||
SerializedPropertyConverter.Write(property, pair.Value);
|
||||
}
|
||||
|
||||
// Applies the changes, registers Undo, and dirties the object/scene.
|
||||
so.ApplyModifiedProperties();
|
||||
GameObjectCommands.MarkDirty(component.gameObject);
|
||||
}
|
||||
|
||||
// Re-read so the caller sees the committed values.
|
||||
return GetComponentProperties(target, type);
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a component either directly (the handle points at a Component) or by GameObject
|
||||
/// handle + type name. Throws a descriptive error when neither resolves.
|
||||
/// </summary>
|
||||
private static Component ResolveComponent(ObjectRef handle, string type)
|
||||
{
|
||||
if (!ObjectResolver.TryResolve(handle, out var obj, out var error))
|
||||
throw new ArgumentException($"Could not resolve 'target': {error}");
|
||||
|
||||
// The handle already points at a specific Component: honour it directly so we never
|
||||
// mutate/remove the wrong instance when the GameObject has several components of the same
|
||||
// type. A 'type' here is only a constraint to validate against, NOT a reason to re-fetch
|
||||
// GetComponent(type) (which returns the first match and would ignore the handle).
|
||||
if (obj is Component directComponent)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(type))
|
||||
{
|
||||
var requestedType = TypeResolver.ResolveComponentType(type);
|
||||
if (requestedType == null)
|
||||
throw new ArgumentException($"Could not resolve component type '{type}'.");
|
||||
if (!requestedType.IsInstanceOfType(directComponent))
|
||||
throw new ArgumentException(
|
||||
$"'target' resolves to a {directComponent.GetType().Name}, which is not a '{requestedType.Name}'.");
|
||||
}
|
||||
|
||||
return directComponent;
|
||||
}
|
||||
|
||||
var go = obj as GameObject;
|
||||
if (go == null)
|
||||
throw new ArgumentException($"'target' did not resolve to a GameObject or Component (got {obj.GetType().Name}).");
|
||||
|
||||
if (string.IsNullOrEmpty(type))
|
||||
throw new ArgumentException("'type' is required when 'target' is a GameObject.");
|
||||
|
||||
var componentType = TypeResolver.ResolveComponentType(type);
|
||||
if (componentType == null)
|
||||
throw new ArgumentException($"Could not resolve component type '{type}'.");
|
||||
|
||||
var component = go.GetComponent(componentType);
|
||||
if (component == null)
|
||||
throw new ArgumentException($"GameObject '{go.name}' has no component of type '{componentType.Name}'.");
|
||||
|
||||
return component;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1a7470ef26f8497595bcbdb84ceb96dc
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Models;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.GameObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Structured result for <c>get_component_properties</c> / <c>set_component_properties</c>.
|
||||
/// WHY it pairs the component identity with the property map: the caller gets back both the
|
||||
/// canonical <see cref="AuthoringResult"/> handle (to reference the component again) and the
|
||||
/// current serialized values in one round trip, which also makes set return the committed state
|
||||
/// for a read-after-write check.
|
||||
/// </summary>
|
||||
sealed class ComponentPropertiesResult
|
||||
{
|
||||
/// <summary>Canonical identity of the component the properties belong to.</summary>
|
||||
[JsonProperty("component")]
|
||||
public AuthoringResult Component { get; set; }
|
||||
|
||||
/// <summary>Map of serialized property name to its JSON value.</summary>
|
||||
[JsonProperty("properties")]
|
||||
public Dictionary<string, JToken> Properties { get; set; } = new Dictionary<string, JToken>();
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e87b3f2873ff4bac9229a28242917ac0
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Models;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.GameObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Structured result for the batch <c>create_gameobjects</c> command (CLI-223). WHY a dedicated
|
||||
/// envelope rather than a bare array: it carries the created <see cref="Count"/> alongside the
|
||||
/// list so an agent can confirm the whole batch landed in one round-trip without inspecting array
|
||||
/// length, and mirrors <see cref="FindGameObjectsResult"/> so the GameObject command surface stays
|
||||
/// consistent. Each entry is the same canonical <see cref="AuthoringResult"/> identity returned by
|
||||
/// the single-object <c>create_gameobject</c>, so any created object can be fed straight back as an
|
||||
/// <see cref="ObjectRef"/> in a follow-up call.
|
||||
/// </summary>
|
||||
sealed class CreateGameObjectsResult
|
||||
{
|
||||
/// <summary>Number of GameObjects created in the batch.</summary>
|
||||
[JsonProperty("count")]
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>Canonical identities of the created GameObjects, in creation order.</summary>
|
||||
[JsonProperty("gameObjects")]
|
||||
public List<AuthoringResult> GameObjects { get; set; } = new List<AuthoringResult>();
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 671f5065c22d6474b80053b6da5a84ef
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Models;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.GameObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Structured result for <c>find_gameobjects</c>. WHY a dedicated envelope rather than a bare
|
||||
/// array: it carries the match <see cref="Count"/> alongside the list so an agent can branch on
|
||||
/// "found / not found / ambiguous" without inspecting array length, and leaves room to add query
|
||||
/// metadata later without breaking the shape. Each entry is the same canonical
|
||||
/// <see cref="AuthoringResult"/> identity returned by the single-object commands, so a result can
|
||||
/// be fed straight back as an <see cref="ObjectRef"/> in a follow-up call.
|
||||
/// </summary>
|
||||
sealed class FindGameObjectsResult
|
||||
{
|
||||
/// <summary>Number of GameObjects that matched the query.</summary>
|
||||
[JsonProperty("count")]
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>Canonical identities of the matching GameObjects.</summary>
|
||||
[JsonProperty("gameObjects")]
|
||||
public List<AuthoringResult> GameObjects { get; set; } = new List<AuthoringResult>();
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1d12bf4a53c14a839c26a91e8ec68292
|
||||
+538
@@ -0,0 +1,538 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.GameObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// GameObject authoring commands (CLI-192): creating GameObjects, querying the scene hierarchy,
|
||||
/// and mutating the core GameObject/Transform surface (parenting, transform, active state, tag,
|
||||
/// layer, name, deletion).
|
||||
///
|
||||
/// WHY these go through the same conventions as the rest of the authoring foundation:
|
||||
/// every mutation is wrapped in an <see cref="AuthoringUndoScope"/> and registered with Unity's
|
||||
/// Undo system so an agent's multi-step action reverts as a single collapsible step. Each
|
||||
/// mutated object is marked dirty (and its scene flagged dirty) so the change survives a Save and
|
||||
/// is visible to the rest of the Editor. Results come back as the canonical
|
||||
/// <see cref="AuthoringResult"/> envelope (via <see cref="ObjectResolver.Describe"/>) so the
|
||||
/// agent can address the same object in a follow-up call.
|
||||
/// </summary>
|
||||
static class GameObjectCommands
|
||||
{
|
||||
/// <summary>
|
||||
/// Create an empty GameObject or a built-in primitive in the active scene.
|
||||
///
|
||||
/// Creation goes through <see cref="GameObject.CreatePrimitive"/> for primitives (so the
|
||||
/// matching mesh + collider are attached exactly as the Editor would) and a plain
|
||||
/// <c>new GameObject</c> for the empty case. The object is registered with
|
||||
/// <see cref="Undo.RegisterCreatedObjectUndo"/> so it can be reverted, and an optional parent
|
||||
/// is resolved by handle so a whole hierarchy can be built in one sequence of calls.
|
||||
/// </summary>
|
||||
[CliCommand("create_gameobject",
|
||||
"Create an empty GameObject or a built-in primitive (cube/sphere/capsule/cylinder/plane/quad) in the active scene.",
|
||||
Tags = new[] { "gameobjects" })]
|
||||
public static AuthoringResult CreateGameObject(
|
||||
[CliArg("name", "Name for the new GameObject. Defaults to 'GameObject' (or the primitive name).")] string name = null,
|
||||
[CliArg("primitive", "Optional primitive type: cube, sphere, capsule, cylinder, plane, quad. Omit for an empty GameObject.")] string primitive = null,
|
||||
[CliArg("parent", "Optional parent handle (globalId/path/guid/instanceId/hierarchyPath). The new object becomes a child of it.")] ObjectRef parent = null)
|
||||
{
|
||||
// Resolve the parent once, outside the scope, so a bad handle fails before any creation.
|
||||
var parentTransform = ResolveParentTransform(parent);
|
||||
|
||||
using (new AuthoringUndoScope("Create GameObject"))
|
||||
{
|
||||
var go = CreateOne(primitive, name, parentTransform);
|
||||
MarkDirty(go);
|
||||
return ObjectResolver.Describe(go);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create N GameObjects (empty or a primitive) in one server round-trip (CLI-223).
|
||||
///
|
||||
/// WHY a separate plural command rather than overloading <see cref="CreateGameObject"/>: the
|
||||
/// single command's contract — one call returns one <see cref="AuthoringResult"/> — is relied
|
||||
/// on by existing callers and tests, so it is left untouched. This plural command is the batch
|
||||
/// surface and returns a <see cref="CreateGameObjectsResult"/> envelope (count + per-item
|
||||
/// identities). When <paramref name="count"/> > 1 and no explicit name array is supplied,
|
||||
/// each object is suffixed Name1..NameN so they remain individually addressable.
|
||||
///
|
||||
/// Per-index <paramref name="positions"/>/<paramref name="rotations"/>/<paramref name="scales"/>
|
||||
/// are arrays of [x,y,z] vectors. When a vectors array is supplied its length MUST equal
|
||||
/// <paramref name="count"/> (a mismatch is a validation error). Omitted channels leave the
|
||||
/// object at its creation default (origin / identity / unit scale). The whole batch is wrapped
|
||||
/// in ONE <see cref="AuthoringUndoScope"/> so it reverts as a single Undo step.
|
||||
/// </summary>
|
||||
[CliCommand("create_gameobjects",
|
||||
"Batch-create N empty GameObjects or primitives in one call. Optional positions/rotations/scales are arrays of [x,y,z] (length must equal count). Returns the created identities.",
|
||||
Tags = new[] { "gameobjects" })]
|
||||
public static CreateGameObjectsResult CreateGameObjects(
|
||||
[CliArg("name", "Base name. With count>1 and no explicit names, objects are suffixed Name1..NameN.")] string name = null,
|
||||
[CliArg("primitive", "Optional primitive type: cube, sphere, capsule, cylinder, plane, quad. Omit for empty GameObjects.")] string primitive = null,
|
||||
[CliArg("parent", "Optional parent handle. Every created object becomes a child of it.")] ObjectRef parent = null,
|
||||
[CliArg("count", "How many GameObjects to create. Default 1.")] int count = 1,
|
||||
[CliArg("positions", "Local positions, one [x,y,z] per object. Length must equal count when supplied.")] float[][] positions = null,
|
||||
[CliArg("rotations", "Local Euler rotations (degrees), one [x,y,z] per object. Length must equal count when supplied.")] float[][] rotations = null,
|
||||
[CliArg("scales", "Local scales, one [x,y,z] per object. Length must equal count when supplied.")] float[][] scales = null)
|
||||
{
|
||||
if (count < 1)
|
||||
throw new ArgumentException($"'count' must be at least 1, got {count}.");
|
||||
|
||||
ValidateVectorArray(positions, count, "positions");
|
||||
ValidateVectorArray(rotations, count, "rotations");
|
||||
ValidateVectorArray(scales, count, "scales");
|
||||
|
||||
// Resolve the parent once, outside the scope, so a bad handle fails before any creation.
|
||||
var parentTransform = ResolveParentTransform(parent);
|
||||
|
||||
var result = new CreateGameObjectsResult();
|
||||
using (new AuthoringUndoScope("Create GameObjects"))
|
||||
{
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
// count==1 keeps the bare name (no "1" suffix) so a single-item batch reads
|
||||
// naturally; count>1 suffixes 1..N for individual addressability.
|
||||
var itemName = count > 1 && !string.IsNullOrEmpty(name) ? name + (i + 1) : name;
|
||||
var go = CreateOne(primitive, itemName, parentTransform);
|
||||
|
||||
// Index the arg name so a null/mis-shaped entry names the offending element
|
||||
// (a JSON array may contain a null entry even when its length matches count).
|
||||
if (positions != null)
|
||||
go.transform.localPosition = ToVector3(positions[i], $"positions[{i}]");
|
||||
if (rotations != null)
|
||||
go.transform.localEulerAngles = ToVector3(rotations[i], $"rotations[{i}]");
|
||||
if (scales != null)
|
||||
go.transform.localScale = ToVector3(scales[i], $"scales[{i}]");
|
||||
|
||||
MarkDirty(go);
|
||||
result.GameObjects.Add(ObjectResolver.Describe(go));
|
||||
}
|
||||
}
|
||||
|
||||
result.Count = result.GameObjects.Count;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find GameObjects in the loaded scenes by name, tag, component type, and/or hierarchy path.
|
||||
/// All supplied filters are combined (AND). Returns the canonical identity of each match so an
|
||||
/// agent can act on the results without a second lookup.
|
||||
///
|
||||
/// This is intentionally a structured query rather than a single-object resolve: agents need
|
||||
/// to discover what exists before they can reference it, and the result set carries the same
|
||||
/// <see cref="AuthoringResult"/> identities used everywhere else.
|
||||
/// </summary>
|
||||
[CliCommand("find_gameobjects",
|
||||
"Find GameObjects in loaded scenes by name, tag, component type, and/or hierarchy path (filters are combined). Returns structured identities.",
|
||||
Tags = new[] { "gameobjects" })]
|
||||
public static FindGameObjectsResult FindGameObjects(
|
||||
[CliArg("name", "Exact name to match.")] string name = null,
|
||||
[CliArg("tag", "Tag to match (e.g. 'Player').")] string tag = null,
|
||||
[CliArg("type", "Component type name to match (e.g. 'Rigidbody', 'UnityEngine.Camera').")] string type = null,
|
||||
[CliArg("hierarchy_path", "Exact hierarchy path to match (e.g. '/Root/Child').")] string hierarchyPath = null,
|
||||
[CliArg("include_inactive", "Include inactive GameObjects. Default true.")] bool includeInactive = true)
|
||||
{
|
||||
Type componentType = null;
|
||||
if (!string.IsNullOrEmpty(type))
|
||||
{
|
||||
componentType = TypeResolver.ResolveComponentType(type);
|
||||
if (componentType == null)
|
||||
throw new ArgumentException($"Could not resolve component type '{type}'.");
|
||||
}
|
||||
|
||||
// Resolve the hierarchy filter once so it is comparable to each object's computed path.
|
||||
var hierarchyFilter = string.IsNullOrEmpty(hierarchyPath) ? null : NormalizeHierarchyPath(hierarchyPath);
|
||||
|
||||
// Validate the tag up front: comparing against an undefined tag (CompareTag / .tag) would
|
||||
// log a Unity error per object. An unknown tag simply yields no matches.
|
||||
if (!string.IsNullOrEmpty(tag) && !InternalEditorTagExists(tag))
|
||||
return new FindGameObjectsResult { Count = 0, GameObjects = new List<AuthoringResult>() };
|
||||
|
||||
var matches = new List<AuthoringResult>();
|
||||
foreach (var go in EnumerateSceneGameObjects(includeInactive))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(name) && go.name != name)
|
||||
continue;
|
||||
|
||||
if (!string.IsNullOrEmpty(tag) && go.tag != tag)
|
||||
continue;
|
||||
|
||||
if (componentType != null && go.GetComponent(componentType) == null)
|
||||
continue;
|
||||
|
||||
if (hierarchyFilter != null && GetHierarchyPath(go) != hierarchyFilter)
|
||||
continue;
|
||||
|
||||
var described = ObjectResolver.Describe(go);
|
||||
if (described != null)
|
||||
matches.Add(described);
|
||||
}
|
||||
|
||||
return new FindGameObjectsResult { Count = matches.Count, GameObjects = matches };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set the local transform (position, rotation in Euler degrees, scale) of a GameObject.
|
||||
/// Any omitted channel is left unchanged. Goes through <see cref="Undo.RegisterCompleteObjectUndo"/>
|
||||
/// on the Transform so the change is reversible and recorded as a prefab override where relevant.
|
||||
/// </summary>
|
||||
[CliCommand("set_transform",
|
||||
"Set a GameObject's local position/rotation(euler)/scale. Omitted channels are left unchanged.",
|
||||
Tags = new[] { "gameobjects" })]
|
||||
public static AuthoringResult SetTransform(
|
||||
[CliArg("target", "Handle of the GameObject to modify.", Required = true)] ObjectRef target,
|
||||
[CliArg("position", "Local position as [x,y,z].")] float[] position = null,
|
||||
[CliArg("rotation", "Local rotation as Euler angles [x,y,z] in degrees.")] float[] rotation = null,
|
||||
[CliArg("scale", "Local scale as [x,y,z].")] float[] scale = null)
|
||||
{
|
||||
var go = ResolveGameObject(target, "target");
|
||||
using (new AuthoringUndoScope("Set Transform"))
|
||||
{
|
||||
var transform = go.transform;
|
||||
Undo.RegisterCompleteObjectUndo(transform, "Set Transform");
|
||||
|
||||
if (position != null)
|
||||
transform.localPosition = ToVector3(position, "position");
|
||||
if (rotation != null)
|
||||
transform.localEulerAngles = ToVector3(rotation, "rotation");
|
||||
if (scale != null)
|
||||
transform.localScale = ToVector3(scale, "scale");
|
||||
|
||||
MarkDirty(go);
|
||||
return ObjectResolver.Describe(go);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reparent a GameObject (or clear its parent so it becomes a scene root). Uses
|
||||
/// <see cref="Undo.SetTransformParent"/> so the reparent — including the sibling-index change —
|
||||
/// is a single undoable operation. World position is preserved by default to match the
|
||||
/// Editor's drag-to-reparent behavior.
|
||||
/// </summary>
|
||||
[CliCommand("set_parent",
|
||||
"Reparent a GameObject under a new parent, or detach it to scene root when no parent is given.",
|
||||
Tags = new[] { "gameobjects" })]
|
||||
public static AuthoringResult SetParent(
|
||||
[CliArg("target", "Handle of the GameObject to reparent.", Required = true)] ObjectRef target,
|
||||
[CliArg("parent", "Handle of the new parent. Omit (or empty) to move the object to the scene root.")] ObjectRef parent = null,
|
||||
[CliArg("world_position_stays", "Keep the object's world position when reparenting. Default true.")] bool worldPositionStays = true)
|
||||
{
|
||||
var go = ResolveGameObject(target, "target");
|
||||
Transform parentTransform = null;
|
||||
if (parent != null && !parent.IsEmpty)
|
||||
parentTransform = ResolveGameObject(parent, "parent").transform;
|
||||
|
||||
using (new AuthoringUndoScope("Set Parent"))
|
||||
{
|
||||
Undo.SetTransformParent(go.transform, parentTransform, worldPositionStays, "Set Parent");
|
||||
MarkDirty(go);
|
||||
return ObjectResolver.Describe(go);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Set a GameObject's active self-state.</summary>
|
||||
[CliCommand("set_active", "Set a GameObject's active self-state (activeSelf).", Tags = new[] { "gameobjects" })]
|
||||
public static AuthoringResult SetActive(
|
||||
[CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target,
|
||||
[CliArg("active", "Desired active state.", Required = true)] bool active)
|
||||
{
|
||||
var go = ResolveGameObject(target, "target");
|
||||
using (new AuthoringUndoScope("Set Active"))
|
||||
{
|
||||
Undo.RegisterCompleteObjectUndo(go, "Set Active");
|
||||
go.SetActive(active);
|
||||
MarkDirty(go);
|
||||
return ObjectResolver.Describe(go);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set a GameObject's tag. The tag must already exist in the project's Tag Manager; an unknown
|
||||
/// tag surfaces a clear error rather than silently creating one (tag creation is a project
|
||||
/// settings mutation outside this command's scope).
|
||||
/// </summary>
|
||||
[CliCommand("set_tag", "Set a GameObject's tag (the tag must already exist in the project).", Tags = new[] { "gameobjects" })]
|
||||
public static AuthoringResult SetTag(
|
||||
[CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target,
|
||||
[CliArg("tag", "Tag to assign (must exist in the Tag Manager).", Required = true)] string tag)
|
||||
{
|
||||
var go = ResolveGameObject(target, "target");
|
||||
if (!InternalEditorTagExists(tag))
|
||||
throw new ArgumentException($"Tag '{tag}' does not exist in the project. Add it via the Tag Manager first.");
|
||||
|
||||
using (new AuthoringUndoScope("Set Tag"))
|
||||
{
|
||||
Undo.RegisterCompleteObjectUndo(go, "Set Tag");
|
||||
go.tag = tag;
|
||||
MarkDirty(go);
|
||||
return ObjectResolver.Describe(go);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set a GameObject's layer by name or numeric index. A name is resolved via
|
||||
/// <see cref="LayerMask.NameToLayer"/>; an out-of-range index or unknown name is rejected.
|
||||
/// </summary>
|
||||
[CliCommand("set_layer", "Set a GameObject's layer by name or numeric index (0-31).", Tags = new[] { "gameobjects" })]
|
||||
public static AuthoringResult SetLayer(
|
||||
[CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target,
|
||||
[CliArg("layer", "Layer name (e.g. 'UI') or numeric index 0-31.", Required = true)] string layer)
|
||||
{
|
||||
var go = ResolveGameObject(target, "target");
|
||||
var layerIndex = ResolveLayer(layer);
|
||||
|
||||
using (new AuthoringUndoScope("Set Layer"))
|
||||
{
|
||||
Undo.RegisterCompleteObjectUndo(go, "Set Layer");
|
||||
go.layer = layerIndex;
|
||||
MarkDirty(go);
|
||||
return ObjectResolver.Describe(go);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rename a GameObject.</summary>
|
||||
[CliCommand("rename_gameobject", "Rename a GameObject.", Tags = new[] { "gameobjects" })]
|
||||
public static AuthoringResult RenameGameObject(
|
||||
[CliArg("target", "Handle of the GameObject.", Required = true)] ObjectRef target,
|
||||
[CliArg("name", "New name.", Required = true)] string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
throw new ArgumentException("New name must not be empty.");
|
||||
|
||||
var go = ResolveGameObject(target, "target");
|
||||
using (new AuthoringUndoScope("Rename GameObject"))
|
||||
{
|
||||
Undo.RegisterCompleteObjectUndo(go, "Rename GameObject");
|
||||
go.name = name;
|
||||
MarkDirty(go);
|
||||
return ObjectResolver.Describe(go);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a GameObject from the scene. Uses <see cref="Undo.DestroyObjectImmediate"/> so the
|
||||
/// deletion is reversible. The result describes the object's identity captured *before*
|
||||
/// destruction (the live object is gone afterwards).
|
||||
/// </summary>
|
||||
[CliCommand("delete_gameobject", "Delete a GameObject from the scene (reversible via Undo).", Tags = new[] { "gameobjects" })]
|
||||
public static AuthoringResult DeleteGameObject(
|
||||
[CliArg("target", "Handle of the GameObject to delete.", Required = true)] ObjectRef target)
|
||||
{
|
||||
var go = ResolveGameObject(target, "target");
|
||||
// Capture identity before destruction; the live object is invalid afterwards.
|
||||
var described = ObjectResolver.Describe(go);
|
||||
var scene = go.scene;
|
||||
|
||||
using (new AuthoringUndoScope("Delete GameObject"))
|
||||
{
|
||||
Undo.DestroyObjectImmediate(go);
|
||||
if (scene.IsValid())
|
||||
EditorSceneManager.MarkSceneDirty(scene);
|
||||
}
|
||||
|
||||
return described;
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Resolve an <see cref="ObjectRef"/> to a GameObject. Accepts either a GameObject handle or a
|
||||
/// Component handle (in which case its owning GameObject is used), matching how agents tend to
|
||||
/// hold references. Throws a descriptive error rather than returning null so the command
|
||||
/// surfaces a clear message to the caller.
|
||||
/// </summary>
|
||||
internal static GameObject ResolveGameObject(ObjectRef handle, string argName)
|
||||
{
|
||||
if (!ObjectResolver.TryResolve(handle, out var obj, out var error))
|
||||
throw new ArgumentException($"Could not resolve '{argName}': {error}");
|
||||
|
||||
var go = obj as GameObject ?? (obj as Component)?.gameObject;
|
||||
if (go == null)
|
||||
throw new ArgumentException($"'{argName}' did not resolve to a GameObject (got {obj.GetType().Name}).");
|
||||
|
||||
return go;
|
||||
}
|
||||
|
||||
/// <summary>Mark a GameObject and its scene dirty so changes persist and the Editor refreshes.</summary>
|
||||
internal static void MarkDirty(GameObject go)
|
||||
{
|
||||
EditorUtility.SetDirty(go);
|
||||
if (go.scene.IsValid())
|
||||
EditorSceneManager.MarkSceneDirty(go.scene);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a single GameObject (empty or a primitive), apply an optional name, register it for
|
||||
/// Undo, and parent it under <paramref name="parentTransform"/> when given. Shared by the
|
||||
/// single <see cref="CreateGameObject"/> and the batch <see cref="CreateGameObjects"/> so both
|
||||
/// build objects identically. MUST be called inside an <see cref="AuthoringUndoScope"/>.
|
||||
/// </summary>
|
||||
private static GameObject CreateOne(string primitive, string name, Transform parentTransform)
|
||||
{
|
||||
GameObject go;
|
||||
if (!string.IsNullOrEmpty(primitive))
|
||||
{
|
||||
if (!TryParsePrimitive(primitive, out var primitiveType))
|
||||
throw new ArgumentException(
|
||||
$"Unknown primitive '{primitive}'. Expected one of: cube, sphere, capsule, cylinder, plane, quad.");
|
||||
|
||||
go = GameObject.CreatePrimitive(primitiveType);
|
||||
}
|
||||
else
|
||||
{
|
||||
go = new GameObject();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
go.name = name;
|
||||
|
||||
Undo.RegisterCreatedObjectUndo(go, "Create GameObject");
|
||||
|
||||
if (parentTransform != null)
|
||||
Undo.SetTransformParent(go.transform, parentTransform, "Create GameObject");
|
||||
|
||||
return go;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve an optional parent handle to its Transform, or null when no parent is supplied.
|
||||
/// Resolved before any creation so an invalid handle fails fast (and a batch creates nothing).
|
||||
/// </summary>
|
||||
private static Transform ResolveParentTransform(ObjectRef parent)
|
||||
{
|
||||
if (parent == null || parent.IsEmpty)
|
||||
return null;
|
||||
return ResolveGameObject(parent, "parent").transform;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate a per-item vectors array (positions/rotations/scales) up front, before any object is
|
||||
/// created. When supplied it must have exactly one entry per object, and every entry must be a
|
||||
/// non-null [x,y,z] triple (a JSON array can carry a null element even when its length matches
|
||||
/// count). A bad array is an agent error surfaced as an <see cref="ArgumentException"/> naming
|
||||
/// the offending index, so a batch applies fully or creates nothing rather than throwing an
|
||||
/// NRE part-way through.
|
||||
/// </summary>
|
||||
private static void ValidateVectorArray(float[][] vectors, int count, string argName)
|
||||
{
|
||||
if (vectors == null)
|
||||
return;
|
||||
|
||||
if (vectors.Length != count)
|
||||
throw new ArgumentException(
|
||||
$"'{argName}' has {vectors.Length} entries but count is {count}; they must match.");
|
||||
|
||||
for (int i = 0; i < vectors.Length; i++)
|
||||
{
|
||||
if (vectors[i] == null)
|
||||
throw new ArgumentException($"'{argName}[{i}]' must be an [x,y,z] array but was null.");
|
||||
if (vectors[i].Length != 3)
|
||||
throw new ArgumentException(
|
||||
$"'{argName}[{i}]' must have exactly 3 components [x,y,z], got {vectors[i].Length}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParsePrimitive(string value, out PrimitiveType primitiveType)
|
||||
{
|
||||
switch (value.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "cube": primitiveType = PrimitiveType.Cube; return true;
|
||||
case "sphere": primitiveType = PrimitiveType.Sphere; return true;
|
||||
case "capsule": primitiveType = PrimitiveType.Capsule; return true;
|
||||
case "cylinder": primitiveType = PrimitiveType.Cylinder; return true;
|
||||
case "plane": primitiveType = PrimitiveType.Plane; return true;
|
||||
case "quad": primitiveType = PrimitiveType.Quad; return true;
|
||||
default: primitiveType = default; return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector3 ToVector3(float[] values, string argName)
|
||||
{
|
||||
if (values == null)
|
||||
throw new ArgumentException($"'{argName}' must be an [x,y,z] array but was null.");
|
||||
if (values.Length != 3)
|
||||
throw new ArgumentException($"'{argName}' must have exactly 3 components [x,y,z], got {values.Length}.");
|
||||
return new Vector3(values[0], values[1], values[2]);
|
||||
}
|
||||
|
||||
private static int ResolveLayer(string layer)
|
||||
{
|
||||
if (int.TryParse(layer, out var index))
|
||||
{
|
||||
if (index < 0 || index > 31)
|
||||
throw new ArgumentException($"Layer index {index} is out of range (0-31).");
|
||||
return index;
|
||||
}
|
||||
|
||||
var named = LayerMask.NameToLayer(layer);
|
||||
if (named < 0)
|
||||
throw new ArgumentException($"Layer '{layer}' does not exist in the project.");
|
||||
return named;
|
||||
}
|
||||
|
||||
private static bool InternalEditorTagExists(string tag)
|
||||
{
|
||||
return UnityEditorInternal.InternalEditorUtility.tags.Contains(tag);
|
||||
}
|
||||
|
||||
private static IEnumerable<GameObject> EnumerateSceneGameObjects(bool includeInactive)
|
||||
{
|
||||
for (int s = 0; s < SceneManager.sceneCount; s++)
|
||||
{
|
||||
var scene = SceneManager.GetSceneAt(s);
|
||||
if (!scene.isLoaded)
|
||||
continue;
|
||||
|
||||
foreach (var root in scene.GetRootGameObjects())
|
||||
{
|
||||
foreach (var go in EnumerateRecursive(root, includeInactive))
|
||||
yield return go;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<GameObject> EnumerateRecursive(GameObject go, bool includeInactive)
|
||||
{
|
||||
if (!includeInactive && !go.activeInHierarchy)
|
||||
yield break;
|
||||
|
||||
yield return go;
|
||||
|
||||
var transform = go.transform;
|
||||
for (int i = 0; i < transform.childCount; i++)
|
||||
{
|
||||
foreach (var child in EnumerateRecursive(transform.GetChild(i).gameObject, includeInactive))
|
||||
yield return child;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetHierarchyPath(GameObject go)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(go.name);
|
||||
var parent = go.transform.parent;
|
||||
while (parent != null)
|
||||
{
|
||||
sb.Insert(0, parent.name + "/");
|
||||
parent = parent.parent;
|
||||
}
|
||||
|
||||
return "/" + sb;
|
||||
}
|
||||
|
||||
private static string NormalizeHierarchyPath(string path)
|
||||
{
|
||||
var trimmed = "/" + path.Trim().Trim('/');
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a1aac051d9234c78b4918a8dd366514a
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
using System;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.GameObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads and writes a single <see cref="SerializedProperty"/> from/to a JSON value.
|
||||
///
|
||||
/// WHY everything goes through SerializedProperty rather than reflection on the C# field: it is
|
||||
/// the only path that correctly participates in dirtying, prefab override tracking, and Undo
|
||||
/// (when the change is applied via <see cref="SerializedObject.ApplyModifiedProperties"/>).
|
||||
/// Reflection would bypass all three and leave the Editor's view stale.
|
||||
///
|
||||
/// Supported surface: primitives (bool/int/long/float/double/string), enums (by name or index),
|
||||
/// <see cref="Vector2"/>/<see cref="Vector3"/>/<see cref="Vector4"/>, <see cref="Color"/>, and
|
||||
/// object references (assigned from an <see cref="ObjectRef"/> handle resolved by
|
||||
/// <see cref="ObjectResolver"/>).
|
||||
/// </summary>
|
||||
internal static class SerializedPropertyConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Read a property into a JSON-friendly value (primitive, string, or small array/object for
|
||||
/// vector/color types). Unsupported property kinds return a short type descriptor string so a
|
||||
/// get_component_properties dump never throws on an exotic field.
|
||||
/// </summary>
|
||||
public static JToken Read(SerializedProperty property)
|
||||
{
|
||||
// Array/list fields surface as Generic with isArray=true; return their elements as a JSON
|
||||
// array so a get/set round-trips. (String also reports isArray, hence the exclusion.)
|
||||
if (property.isArray && property.propertyType != SerializedPropertyType.String)
|
||||
{
|
||||
var elements = new JArray();
|
||||
for (int i = 0; i < property.arraySize; i++)
|
||||
elements.Add(Read(property.GetArrayElementAtIndex(i)));
|
||||
return elements;
|
||||
}
|
||||
|
||||
switch (property.propertyType)
|
||||
{
|
||||
case SerializedPropertyType.Boolean:
|
||||
return new JValue(property.boolValue);
|
||||
case SerializedPropertyType.Integer:
|
||||
return new JValue(property.longValue);
|
||||
case SerializedPropertyType.Float:
|
||||
return new JValue(property.doubleValue);
|
||||
case SerializedPropertyType.String:
|
||||
return new JValue(property.stringValue);
|
||||
case SerializedPropertyType.Enum:
|
||||
// enumValueIndex indexes enumNames; guard against -1 (mixed/unknown).
|
||||
return property.enumValueIndex >= 0 && property.enumValueIndex < property.enumNames.Length
|
||||
? new JValue(property.enumNames[property.enumValueIndex])
|
||||
: new JValue(property.intValue);
|
||||
case SerializedPropertyType.Vector2:
|
||||
return ToArray(property.vector2Value.x, property.vector2Value.y);
|
||||
case SerializedPropertyType.Vector3:
|
||||
return ToArray(property.vector3Value.x, property.vector3Value.y, property.vector3Value.z);
|
||||
case SerializedPropertyType.Vector4:
|
||||
return ToArray(property.vector4Value.x, property.vector4Value.y, property.vector4Value.z, property.vector4Value.w);
|
||||
case SerializedPropertyType.Color:
|
||||
var c = property.colorValue;
|
||||
return ToArray(c.r, c.g, c.b, c.a);
|
||||
case SerializedPropertyType.ObjectReference:
|
||||
return property.objectReferenceValue != null
|
||||
? JToken.FromObject(ObjectResolver.Describe(property.objectReferenceValue))
|
||||
: JValue.CreateNull();
|
||||
default:
|
||||
// Bounds, Quaternion, AnimationCurve, etc. are out of MVP scope: surface a hint.
|
||||
return new JValue($"<unsupported:{property.propertyType}>");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write a JSON value into a property. Throws <see cref="ArgumentException"/> with a clear
|
||||
/// message on a type mismatch so set_component_properties reports exactly which field/value
|
||||
/// failed. Object references are resolved from an <see cref="ObjectRef"/>-shaped JSON object
|
||||
/// (or a bare string treated as an asset path / globalId).
|
||||
/// </summary>
|
||||
public static void Write(SerializedProperty property, JToken value)
|
||||
{
|
||||
// Array/list fields (Generic + isArray) are written from a JSON array: resize, then recurse
|
||||
// per element so arrays of primitives AND object references (e.g. MeshRenderer.m_Materials)
|
||||
// both work. (String also reports isArray, hence the exclusion.)
|
||||
if (property.isArray && property.propertyType != SerializedPropertyType.String)
|
||||
{
|
||||
if (!(value is JArray array))
|
||||
throw new ArgumentException(
|
||||
$"Property '{property.name}' is an array; provide a JSON array of elements.");
|
||||
|
||||
property.arraySize = array.Count;
|
||||
for (int i = 0; i < array.Count; i++)
|
||||
Write(property.GetArrayElementAtIndex(i), array[i]);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (property.propertyType)
|
||||
{
|
||||
case SerializedPropertyType.Boolean:
|
||||
property.boolValue = value.ToObject<bool>();
|
||||
break;
|
||||
case SerializedPropertyType.Integer:
|
||||
property.longValue = value.ToObject<long>();
|
||||
break;
|
||||
case SerializedPropertyType.Float:
|
||||
property.doubleValue = value.ToObject<double>();
|
||||
break;
|
||||
case SerializedPropertyType.String:
|
||||
property.stringValue = value.Type == JTokenType.Null ? string.Empty : value.ToObject<string>();
|
||||
break;
|
||||
case SerializedPropertyType.Enum:
|
||||
WriteEnum(property, value);
|
||||
break;
|
||||
case SerializedPropertyType.Vector2:
|
||||
{
|
||||
var a = ToFloats(value, 2, property.name);
|
||||
property.vector2Value = new Vector2(a[0], a[1]);
|
||||
break;
|
||||
}
|
||||
case SerializedPropertyType.Vector3:
|
||||
{
|
||||
var a = ToFloats(value, 3, property.name);
|
||||
property.vector3Value = new Vector3(a[0], a[1], a[2]);
|
||||
break;
|
||||
}
|
||||
case SerializedPropertyType.Vector4:
|
||||
{
|
||||
var a = ToFloats(value, 4, property.name);
|
||||
property.vector4Value = new Vector4(a[0], a[1], a[2], a[3]);
|
||||
break;
|
||||
}
|
||||
case SerializedPropertyType.Color:
|
||||
{
|
||||
var a = ToFloats(value, 4, property.name);
|
||||
property.colorValue = new Color(a[0], a[1], a[2], a[3]);
|
||||
break;
|
||||
}
|
||||
case SerializedPropertyType.ObjectReference:
|
||||
property.objectReferenceValue = ResolveObjectReference(value, property.name);
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException(
|
||||
$"Property '{property.name}' has unsupported type '{property.propertyType}' for set.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteEnum(SerializedProperty property, JToken value)
|
||||
{
|
||||
// Accept either the enum constant name or its index/value.
|
||||
if (value.Type == JTokenType.String)
|
||||
{
|
||||
var name = value.ToObject<string>();
|
||||
var index = Array.IndexOf(property.enumNames, name);
|
||||
if (index < 0)
|
||||
{
|
||||
// Fall back to display names (Editor humanized form), then error.
|
||||
index = Array.IndexOf(property.enumDisplayNames, name);
|
||||
if (index < 0)
|
||||
throw new ArgumentException(
|
||||
$"Enum value '{name}' is not valid for property '{property.name}'. Valid: [{string.Join(", ", property.enumNames)}].");
|
||||
}
|
||||
|
||||
property.enumValueIndex = index;
|
||||
}
|
||||
else
|
||||
{
|
||||
// A numeric value is the enum's underlying integer, NOT an index into enumNames:
|
||||
// those only coincide when declaration order matches the constant values. Mirror Read,
|
||||
// which falls back to property.intValue, so round-tripping a numeric enum is stable.
|
||||
property.intValue = value.ToObject<int>();
|
||||
}
|
||||
}
|
||||
|
||||
private static Object ResolveObjectReference(JToken value, string propertyName)
|
||||
{
|
||||
// An explicit null clears the reference; anything else MUST resolve — never silently no-op
|
||||
// (a handle that doesn't resolve used to be dropped, so the command reported success while
|
||||
// assigning nothing).
|
||||
if (value == null || value.Type == JTokenType.Null)
|
||||
return null;
|
||||
|
||||
// Route every shape (string handle, handle object, or bare-integer instanceId) through
|
||||
// ObjectRefConverter so path / guid / globalId / instanceId forms all parse identically.
|
||||
var handle = value.ToObject<ObjectRef>();
|
||||
if (handle == null || handle.IsEmpty)
|
||||
throw new ArgumentException(
|
||||
$"Property '{propertyName}': '{value}' is not a recognized object reference handle. " +
|
||||
"Provide a path, guid, globalId, or instanceId (or null to clear).");
|
||||
|
||||
if (!ObjectResolver.TryResolve(handle, out var obj, out var error))
|
||||
throw new ArgumentException($"Could not resolve object reference for '{propertyName}': {error}");
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
private static float[] ToFloats(JToken value, int expected, string propertyName)
|
||||
{
|
||||
if (value is JArray array)
|
||||
{
|
||||
if (array.Count != expected)
|
||||
throw new ArgumentException(
|
||||
$"Property '{propertyName}' expects {expected} components, got {array.Count}.");
|
||||
var result = new float[expected];
|
||||
for (int i = 0; i < expected; i++)
|
||||
result[i] = array[i].ToObject<float>();
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new ArgumentException(
|
||||
$"Property '{propertyName}' expects an array of {expected} numbers.");
|
||||
}
|
||||
|
||||
private static JArray ToArray(params float[] values)
|
||||
{
|
||||
var array = new JArray();
|
||||
foreach (var v in values)
|
||||
array.Add(v);
|
||||
return array;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 85f605c1109e40898fe47ff1e2a8a4d0
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.GameObjects
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a <see cref="UnityEngine.Component"/>-derived <see cref="Type"/> from an
|
||||
/// agent-supplied name. WHY this is non-trivial: agents pass either a short name ("Rigidbody")
|
||||
/// or a fully-qualified one ("UnityEngine.Camera"), and the type may live in any loaded assembly
|
||||
/// (UnityEngine modules, user assemblies, packages). We therefore try, in order: an exact
|
||||
/// fully-qualified <see cref="Type.GetType(string)"/>, then a scan of all loaded assemblies for a
|
||||
/// full-name match, then a short-name match. Only <see cref="Component"/> subclasses are accepted
|
||||
/// so the result is always valid for AddComponent/GetComponent.
|
||||
/// </summary>
|
||||
static class TypeResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve a component type by name, or return null if no unambiguous <see cref="Component"/>
|
||||
/// subclass matches. A short name that matches multiple types in different namespaces is
|
||||
/// treated as ambiguous and returns null (the caller should ask for a fully-qualified name).
|
||||
/// </summary>
|
||||
public static Type ResolveComponentType(string typeName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(typeName))
|
||||
return null;
|
||||
|
||||
typeName = typeName.Trim();
|
||||
|
||||
// 1. Exact, assembly-qualified or fully-qualified lookup.
|
||||
var direct = Type.GetType(typeName, throwOnError: false, ignoreCase: false);
|
||||
if (IsComponent(direct))
|
||||
return direct;
|
||||
|
||||
var assemblies = PipelineUtils.GetLoadedAssemblies();
|
||||
|
||||
// 2. Full-name match across loaded assemblies.
|
||||
var fullNameMatches = new List<Type>();
|
||||
// 3. Short-name match (collected in the same pass) as a fallback.
|
||||
var shortNameMatches = new List<Type>();
|
||||
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
Type[] types;
|
||||
try
|
||||
{
|
||||
types = assembly.GetTypes();
|
||||
}
|
||||
catch (System.Reflection.ReflectionTypeLoadException ex)
|
||||
{
|
||||
types = ex.Types.Where(t => t != null).ToArray();
|
||||
}
|
||||
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (!IsComponent(type))
|
||||
continue;
|
||||
|
||||
if (string.Equals(type.FullName, typeName, StringComparison.Ordinal))
|
||||
fullNameMatches.Add(type);
|
||||
else if (string.Equals(type.Name, typeName, StringComparison.Ordinal))
|
||||
shortNameMatches.Add(type);
|
||||
}
|
||||
}
|
||||
|
||||
if (fullNameMatches.Count == 1)
|
||||
return fullNameMatches[0];
|
||||
if (fullNameMatches.Count > 1)
|
||||
return null; // genuinely ambiguous full names (rare) — caller must disambiguate.
|
||||
|
||||
// Distinct in case the same type is reachable through multiple assembly references.
|
||||
var distinctShort = shortNameMatches.Distinct().ToList();
|
||||
return distinctShort.Count == 1 ? distinctShort[0] : null;
|
||||
}
|
||||
|
||||
private static bool IsComponent(Type type)
|
||||
{
|
||||
return type != null && typeof(Component).IsAssignableFrom(type) && !type.IsAbstract;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f033287d429f4957bfcfd6795f731b48
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4841c84bba2a471eb3fb429f79e3b118
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,405 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Materials
|
||||
{
|
||||
/// <summary>
|
||||
/// Materials authoring commands (CLI-213): read and write a material's shader, shader properties
|
||||
/// (floats, colors, vectors, textures, ints), keywords, and render queue.
|
||||
///
|
||||
/// These sit on the CLI-190 authoring foundation:
|
||||
/// - the material is addressed with an <see cref="ObjectRef"/> resolved by <see cref="ObjectResolver"/>
|
||||
/// and confined to the authoring root (a GUID/globalId handle can't edit a material outside the sandbox),
|
||||
/// - texture property values are <see cref="ObjectRef"/>s likewise confined to the root,
|
||||
/// - writes are wrapped in an <see cref="AuthoringUndoScope"/> with <see cref="Undo.RecordObject"/>
|
||||
/// (material property changes ARE covered by Undo.RecordObject, unlike AssetDatabase ops), then
|
||||
/// <see cref="EditorUtility.SetDirty"/> + <see cref="AssetDatabase.SaveAssetIfDirty"/> persist them,
|
||||
/// - unknown property names and type mismatches are reported in <c>unknown[]</c> rather than failing
|
||||
/// the whole call (as long as at least one input applied).
|
||||
///
|
||||
/// Material CREATION already exists in <c>create_asset</c> (CLI-221); this command set is the
|
||||
/// read/write surface on top of an existing material.
|
||||
/// </summary>
|
||||
static class MaterialCommands
|
||||
{
|
||||
[CliCommand("get_material_properties",
|
||||
"Read a material's shader, render queue, enabled keywords, and all shader properties with their current values (Color as [r,g,b,a], Vector as [x,y,z,w], Texture as an object reference).",
|
||||
Tags = new[] { "materials" })]
|
||||
public static MaterialPropertiesResult GetMaterialProperties(
|
||||
[CliArg("material", "Reference to the .mat asset (or a loaded material) to read (path / guid / globalId / instanceId).", Required = true)] ObjectRef material)
|
||||
{
|
||||
var mat = ResolveMaterial(material, out var assetPath);
|
||||
|
||||
var result = new MaterialPropertiesResult
|
||||
{
|
||||
AssetPath = assetPath,
|
||||
Shader = mat.shader != null ? mat.shader.name : null,
|
||||
// rawRenderQueue is -1 when the material inherits from the shader and a positive
|
||||
// integer when explicitly overridden — returning the raw value preserves the
|
||||
// round-trip contract with set_material_properties renderQueue:-1 (inherit).
|
||||
// Unity 2022 lacks rawRenderQueue; fall back to the effective renderQueue.
|
||||
#if UNITY_6000_0_OR_NEWER
|
||||
RenderQueue = mat.rawRenderQueue,
|
||||
#else
|
||||
RenderQueue = mat.renderQueue,
|
||||
#endif
|
||||
EnabledKeywords = GetEnabledKeywords(mat),
|
||||
};
|
||||
|
||||
var shader = mat.shader;
|
||||
if (shader != null)
|
||||
{
|
||||
// Enumerate the shader's declared properties via the Shader instance APIs (single index
|
||||
// space, matching get_shader_properties), so name/type/range-limits always line up.
|
||||
int count = shader.GetPropertyCount();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var name = shader.GetPropertyName(i);
|
||||
var propType = shader.GetPropertyType(i);
|
||||
|
||||
var entry = new MaterialPropertyValue
|
||||
{
|
||||
Name = name,
|
||||
Type = PublicValueType(propType),
|
||||
Value = MaterialValueConverter.ReadValue(mat, name, propType),
|
||||
};
|
||||
|
||||
if (propType == ShaderPropertyType.Range)
|
||||
{
|
||||
var limits = shader.GetPropertyRangeLimits(i);
|
||||
entry.Range = new MaterialRange
|
||||
{
|
||||
Min = limits.x,
|
||||
Max = limits.y,
|
||||
};
|
||||
}
|
||||
|
||||
result.Properties.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("set_material_properties",
|
||||
"Set shader properties on a material (Float/Range/Int=number; Color=[r,g,b,a] or \"#RRGGBBAA\" hex; Vector=[x,y,z,w]; Texture=an object reference or null to clear), optionally reassign the shader, set the render queue, and toggle keywords. Unknown names / type mismatches are reported in unknown[].",
|
||||
Tags = new[] { "materials" })]
|
||||
public static SetMaterialPropertiesResult SetMaterialProperties(
|
||||
[CliArg("material", "Reference to the .mat asset (or a loaded material) to edit (path / guid / globalId / instanceId).", Required = true)] ObjectRef material,
|
||||
[CliArg("shader", "Reassign the material's shader by name (e.g. \"Standard\", \"Universal Render Pipeline/Lit\", or a Shader Graph shader name). Applied before properties so new property names resolve against the new shader.")] string shader = null,
|
||||
[CliArg("properties", "JSON object of shader property name -> value. Names must include the leading underscore (e.g. _BaseColor). Float/Range/Int=number; Color=[r,g,b,a] or hex string; Vector=[x,y,z,w]; Texture=an object reference {guid/path} or null.")] JObject properties = null,
|
||||
[CliArg("renderQueue", "Explicit render queue, or -1 to inherit from the shader. Omit to leave unchanged.")] int? renderQueue = null,
|
||||
[CliArg("enableKeywords", "Shader keywords to enable (e.g. _NORMALMAP, _EMISSION).")] string[] enableKeywords = null,
|
||||
[CliArg("disableKeywords", "Shader keywords to disable.")] string[] disableKeywords = null,
|
||||
[CliArg("confirm", "Reserved for parity; editing an existing material is non-destructive and undoable, so it is not required.")] bool confirm = false,
|
||||
[CliArg("dry_run", "If true, validate the shader, resolve property names and texture refs, and report applied[]/unknown[] without writing anything.")] bool dryRun = false)
|
||||
{
|
||||
var mat = ResolveMaterial(material, out var assetPath);
|
||||
|
||||
// Resolve the (optional) replacement shader UP FRONT so dry_run validates it too, and a
|
||||
// shader_not_found writes nothing (acceptance #9). Shader.Find only finds compiled/included
|
||||
// shaders — surface that in the hint.
|
||||
Shader newShader = null;
|
||||
if (shader != null)
|
||||
{
|
||||
newShader = Shader.Find(shader);
|
||||
if (newShader == null)
|
||||
throw new ShaderNotFoundException(
|
||||
$"Shader '{shader}' not found. Shader.Find only resolves shaders that are compiled/included in the build (or referenced by a material/scene). Use list_shaders to discover valid names.");
|
||||
}
|
||||
|
||||
var result = new SetMaterialPropertiesResult();
|
||||
|
||||
// ---- dry_run: validate against the would-be shader, never mutate ----------------------
|
||||
if (dryRun)
|
||||
{
|
||||
var effectiveShader = newShader ?? mat.shader;
|
||||
ValidateProperties(effectiveShader, properties, result);
|
||||
if (renderQueue.HasValue)
|
||||
result.Applied.Add("renderQueue");
|
||||
AddKeywordTokens(result.Applied, enableKeywords, disableKeywords);
|
||||
|
||||
GuardAtLeastOneApplied(result, shaderReassigned: newShader != null);
|
||||
|
||||
CopyIdentity(mat, assetPath, result);
|
||||
result.Shader = effectiveShader != null ? effectiveShader.name : null;
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- apply --------------------------------------------------------------------------
|
||||
using (new AuthoringUndoScope("Set Material Properties"))
|
||||
{
|
||||
// Material property/shader/keyword/renderQueue changes are all covered by Undo.RecordObject.
|
||||
Undo.RecordObject(mat, "Set Material Properties");
|
||||
|
||||
if (newShader != null)
|
||||
mat.shader = newShader;
|
||||
|
||||
if (properties != null)
|
||||
{
|
||||
foreach (var pair in properties)
|
||||
{
|
||||
if (pair.Key == "token") // injected by the server; never a material property.
|
||||
continue;
|
||||
|
||||
var name = pair.Key;
|
||||
if (!mat.HasProperty(name))
|
||||
{
|
||||
result.Unknown.Add($"{name}: unknown property (not declared by shader '{(mat.shader != null ? mat.shader.name : "<none>")}')");
|
||||
continue;
|
||||
}
|
||||
|
||||
var propType = GetPropertyType(mat.shader, name);
|
||||
if (propType == null)
|
||||
{
|
||||
// HasProperty was true but the type couldn't be read off the shader (e.g. a
|
||||
// built-in property like unity_Lightmaps not declared in the shader's list).
|
||||
result.Unknown.Add($"{name}: property is not a settable shader property on '{(mat.shader != null ? mat.shader.name : "<none>")}'");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
MaterialValueConverter.ApplyValue(mat, name, propType.Value, pair.Value);
|
||||
result.Applied.Add(name);
|
||||
}
|
||||
catch (MaterialPropertyTypeMismatch mismatch)
|
||||
{
|
||||
result.Unknown.Add(mismatch.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (renderQueue.HasValue)
|
||||
{
|
||||
mat.renderQueue = renderQueue.Value;
|
||||
result.Applied.Add("renderQueue");
|
||||
}
|
||||
|
||||
ApplyKeywords(mat, enableKeywords, disableKeywords, result.Applied);
|
||||
|
||||
GuardAtLeastOneApplied(result, shaderReassigned: newShader != null);
|
||||
|
||||
EditorUtility.SetDirty(mat);
|
||||
if (!string.IsNullOrEmpty(assetPath))
|
||||
AssetDatabase.SaveAssetIfDirty(mat);
|
||||
}
|
||||
|
||||
CopyIdentity(mat, assetPath, result);
|
||||
result.Shader = mat.shader != null ? mat.shader.name : null;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve an <see cref="ObjectRef"/> to a <see cref="Material"/>, confining any on-disk asset to
|
||||
/// the authoring root. Returns the project-relative asset path (null for a non-asset/in-memory
|
||||
/// material). Throws a clear <see cref="ArgumentException"/> on miss / wrong type / out-of-root.
|
||||
/// </summary>
|
||||
internal static Material ResolveMaterial(ObjectRef material, out string assetPath)
|
||||
{
|
||||
if (material == null || material.IsEmpty)
|
||||
throw new ArgumentException("'material' is required.");
|
||||
|
||||
if (!ObjectResolver.TryResolve(material, out var obj, out var error))
|
||||
throw new ArgumentException($"Could not resolve material: {error}");
|
||||
|
||||
if (!(obj is Material mat))
|
||||
throw new ArgumentException($"Reference '{material}' resolved to a {obj.GetType().Name}, not a Material.");
|
||||
|
||||
assetPath = AssetDatabase.GetAssetPath(mat);
|
||||
if (!string.IsNullOrEmpty(assetPath))
|
||||
{
|
||||
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
|
||||
if (confined == null)
|
||||
throw new ArgumentException(
|
||||
$"Material '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
|
||||
assetPath = confined;
|
||||
}
|
||||
|
||||
return mat;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the material's enabled keyword set. Uses <see cref="Material.enabledKeywords"/>
|
||||
/// (LocalKeyword[]) which is the supported API on the package's min Unity version (Unity 6 /
|
||||
/// 6000.0); the legacy <c>shaderKeywords</c> string[] is deprecated.
|
||||
/// </summary>
|
||||
private static List<string> GetEnabledKeywords(Material mat)
|
||||
{
|
||||
var keywords = new List<string>();
|
||||
foreach (var keyword in mat.enabledKeywords)
|
||||
keywords.Add(keyword.name);
|
||||
keywords.Sort(StringComparer.Ordinal);
|
||||
return keywords;
|
||||
}
|
||||
|
||||
private static void ApplyKeywords(Material mat, string[] enable, string[] disable, List<string> applied)
|
||||
{
|
||||
// Use the LocalKeyword API (Unity 2022.1+) so that Enable/Disable and the
|
||||
// enabledKeywords reader are talking to the same keyword table. The legacy
|
||||
// string-based EnableKeyword(string) affects global keywords, whereas
|
||||
// mat.enabledKeywords returns LocalKeyword[], so they would disagree on round-trip.
|
||||
if (enable != null)
|
||||
{
|
||||
foreach (var keyword in enable)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyword) || mat.shader == null)
|
||||
continue;
|
||||
mat.EnableKeyword(new LocalKeyword(mat.shader, keyword));
|
||||
applied.Add($"+keyword:{keyword}");
|
||||
}
|
||||
}
|
||||
|
||||
if (disable != null)
|
||||
{
|
||||
foreach (var keyword in disable)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyword) || mat.shader == null)
|
||||
continue;
|
||||
mat.DisableKeyword(new LocalKeyword(mat.shader, keyword));
|
||||
applied.Add($"-keyword:{keyword}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddKeywordTokens(List<string> applied, string[] enable, string[] disable)
|
||||
{
|
||||
if (enable != null)
|
||||
foreach (var keyword in enable.Where(k => !string.IsNullOrWhiteSpace(k)))
|
||||
applied.Add($"+keyword:{keyword}");
|
||||
if (disable != null)
|
||||
foreach (var keyword in disable.Where(k => !string.IsNullOrWhiteSpace(k)))
|
||||
applied.Add($"-keyword:{keyword}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// dry_run validation: classify each supplied property as applicable (name resolves AND value
|
||||
/// shape matches the declared type) or unknown, WITHOUT mutating the material.
|
||||
/// </summary>
|
||||
private static void ValidateProperties(Shader shader, JObject properties, SetMaterialPropertiesResult result)
|
||||
{
|
||||
if (properties == null)
|
||||
return;
|
||||
|
||||
foreach (var pair in properties)
|
||||
{
|
||||
if (pair.Key == "token")
|
||||
continue;
|
||||
|
||||
var name = pair.Key;
|
||||
var propType = shader != null ? GetPropertyType(shader, name) : null;
|
||||
if (propType == null)
|
||||
{
|
||||
result.Unknown.Add($"{name}: unknown property (not declared by shader '{(shader != null ? shader.name : "<none>")}')");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate the value shape against the type by attempting a conversion on a throwaway
|
||||
// material instance — never the caller's material (dry_run must leave no trace).
|
||||
Material probe = null;
|
||||
try
|
||||
{
|
||||
probe = new Material(shader);
|
||||
MaterialValueConverter.ApplyValue(probe, name, propType.Value, pair.Value);
|
||||
result.Applied.Add(name);
|
||||
}
|
||||
catch (MaterialPropertyTypeMismatch mismatch)
|
||||
{
|
||||
result.Unknown.Add(mismatch.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (probe != null)
|
||||
Object.DestroyImmediate(probe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Look up a property's <see cref="UnityEngine.Rendering.ShaderPropertyType"/> by name on a shader.
|
||||
/// Returns null when the shader has no property with that exact name (case-sensitive; names include
|
||||
/// the leading underscore).
|
||||
/// </summary>
|
||||
internal static ShaderPropertyType? GetPropertyType(Shader shader, string name)
|
||||
{
|
||||
if (shader == null)
|
||||
return null;
|
||||
int count = shader.GetPropertyCount();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
if (shader.GetPropertyName(i) == name)
|
||||
return shader.GetPropertyType(i);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void GuardAtLeastOneApplied(SetMaterialPropertiesResult result, bool shaderReassigned)
|
||||
{
|
||||
// A shader reassignment is itself a successful change, so it satisfies the "at least one
|
||||
// thing applied" guard even when every supplied property is unknown for the new shader.
|
||||
if (!shaderReassigned && result.Applied.Count == 0 && result.Unknown.Count > 0)
|
||||
throw new ArgumentException(
|
||||
$"None of the supplied inputs could be applied (unknown property name or type mismatch): {string.Join("; ", result.Unknown)}.");
|
||||
}
|
||||
|
||||
private static void CopyIdentity(Material mat, string assetPath, SetMaterialPropertiesResult result)
|
||||
{
|
||||
var identity = ObjectResolver.Describe(mat);
|
||||
if (identity != null)
|
||||
{
|
||||
result.GlobalId = identity.GlobalId;
|
||||
result.Guid = identity.Guid;
|
||||
result.FileId = identity.FileId;
|
||||
result.InstanceId = identity.InstanceId;
|
||||
result.HierarchyPath = identity.HierarchyPath;
|
||||
result.Type = identity.Type;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Type = nameof(Material);
|
||||
}
|
||||
|
||||
// Prefer the confined asset path computed by ResolveMaterial.
|
||||
if (!string.IsNullOrEmpty(assetPath))
|
||||
result.AssetPath = assetPath;
|
||||
}
|
||||
|
||||
/// <summary>Map a <see cref="UnityEngine.Rendering.ShaderPropertyType"/> to the value-shape label used by get_material_properties.</summary>
|
||||
private static string PublicValueType(ShaderPropertyType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case ShaderPropertyType.Color: return "Color";
|
||||
case ShaderPropertyType.Vector: return "Vector";
|
||||
case ShaderPropertyType.Float: return "Float";
|
||||
case ShaderPropertyType.Range: return "Range";
|
||||
case ShaderPropertyType.Texture: return "Texture";
|
||||
case ShaderPropertyType.Int: return "Int";
|
||||
default: return type.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a requested shader name does not resolve via <see cref="Shader.Find"/>. Carries the
|
||||
/// stable <c>shader_not_found</c> code (also prefixed onto the message, since the server propagates
|
||||
/// only the exception message over the wire) so a client can distinguish it from other failures.
|
||||
/// </summary>
|
||||
sealed class ShaderNotFoundException : Exception
|
||||
{
|
||||
public const string CodeValue = "shader_not_found";
|
||||
|
||||
public string Code => CodeValue;
|
||||
|
||||
public ShaderNotFoundException(string message) : base($"[{CodeValue}] {message}") { }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a14c4a97ac9e4d61b904de30d1016d92
|
||||
@@ -0,0 +1,147 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Models;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Materials
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of <c>get_material_properties</c> (CLI-213): the material's shader, render queue, enabled
|
||||
/// keywords, and the full list of shader properties with their current values.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
class MaterialPropertiesResult
|
||||
{
|
||||
[JsonProperty("assetPath")]
|
||||
public string AssetPath { get; set; }
|
||||
|
||||
/// <summary>Shader name, e.g. "Universal Render Pipeline/Lit".</summary>
|
||||
[JsonProperty("shader")]
|
||||
public string Shader { get; set; }
|
||||
|
||||
/// <summary>Render queue; -1 means "inherit from shader".</summary>
|
||||
[JsonProperty("renderQueue")]
|
||||
public int RenderQueue { get; set; }
|
||||
|
||||
[JsonProperty("enabledKeywords")]
|
||||
public List<string> EnabledKeywords { get; set; } = new List<string>();
|
||||
|
||||
[JsonProperty("properties")]
|
||||
public List<MaterialPropertyValue> Properties { get; set; } = new List<MaterialPropertyValue>();
|
||||
}
|
||||
|
||||
/// <summary>A single shader property of a material with its current value.</summary>
|
||||
[Serializable]
|
||||
class MaterialPropertyValue
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>"Float" | "Range" | "Color" | "Vector" | "Texture" | "Int".</summary>
|
||||
[JsonProperty("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// number | [r,g,b,a] | [x,y,z,w] | { texture: ObjectRef } | null, per the property type.
|
||||
/// </summary>
|
||||
[JsonProperty("value")]
|
||||
public object Value { get; set; }
|
||||
|
||||
/// <summary>Min/max for a Range property; null otherwise.</summary>
|
||||
[JsonProperty("range", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public MaterialRange Range { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Inclusive min/max bounds of a Range shader property.</summary>
|
||||
[Serializable]
|
||||
class MaterialRange
|
||||
{
|
||||
[JsonProperty("min")]
|
||||
public float Min { get; set; }
|
||||
|
||||
[JsonProperty("max")]
|
||||
public float Max { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <c>set_material_properties</c> (CLI-213): the canonical <see cref="AuthoringResult"/>
|
||||
/// identity of the edited material, extended with which properties applied vs. were unknown (with a
|
||||
/// reason), and the material's (possibly reassigned) shader name.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
class SetMaterialPropertiesResult : AuthoringResult
|
||||
{
|
||||
/// <summary>Shader name after any reassignment.</summary>
|
||||
[JsonProperty("shader")]
|
||||
public string Shader { get; set; }
|
||||
|
||||
/// <summary>Property names (and keyword/renderQueue tokens) that were applied.</summary>
|
||||
[JsonProperty("applied")]
|
||||
public List<string> Applied { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Inputs that could not be applied, each as "name: reason" (unknown property name, or a type
|
||||
/// mismatch like "_Metallic: expected Float, got array").
|
||||
/// </summary>
|
||||
[JsonProperty("unknown")]
|
||||
public List<string> Unknown { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
/// <summary>A single discovered shader entry from <c>list_shaders</c>.</summary>
|
||||
[Serializable]
|
||||
class ShaderInfo
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>Project asset path for a project shader; null for a built-in/engine shader.</summary>
|
||||
[JsonProperty("assetPath")]
|
||||
public string AssetPath { get; set; }
|
||||
|
||||
[JsonProperty("isBuiltin")]
|
||||
public bool IsBuiltin { get; set; }
|
||||
|
||||
/// <summary>Reflects <c>Shader.isSupported</c> for the active render pipeline.</summary>
|
||||
[JsonProperty("isSupported")]
|
||||
public bool IsSupported { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Result of <c>get_shader_properties</c> (CLI-213): a shader's declared property list.</summary>
|
||||
[Serializable]
|
||||
class ShaderPropertiesResult
|
||||
{
|
||||
[JsonProperty("shader")]
|
||||
public string Shader { get; set; }
|
||||
|
||||
[JsonProperty("properties")]
|
||||
public List<ShaderPropertyInfo> Properties { get; set; } = new List<ShaderPropertyInfo>();
|
||||
}
|
||||
|
||||
/// <summary>A single declared shader property, introspected via <c>ShaderUtil</c>.</summary>
|
||||
[Serializable]
|
||||
class ShaderPropertyInfo
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>ShaderUtil property description (the UI label).</summary>
|
||||
[JsonProperty("description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
/// <summary>"Color" | "Vector" | "Float" | "Range" | "TexEnv" | "Int".</summary>
|
||||
[JsonProperty("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
/// <summary>Min/max for a Range property; null otherwise.</summary>
|
||||
[JsonProperty("range", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public MaterialRange Range { get; set; }
|
||||
|
||||
/// <summary>"Tex2D" | "Cube" | "Tex3D" | "Tex2DArray" for a TexEnv property; null otherwise.</summary>
|
||||
[JsonProperty("textureDimension", NullValueHandling = NullValueHandling.Ignore)]
|
||||
public string TextureDimension { get; set; }
|
||||
|
||||
/// <summary>Property flags, e.g. "HideInInspector", "Normal", "HDR", "NoScaleOffset".</summary>
|
||||
[JsonProperty("flags")]
|
||||
public List<string> Flags { get; set; } = new List<string>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 52b9d1506e9b4a95982ff554d70179b3
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Materials
|
||||
{
|
||||
/// <summary>
|
||||
/// Bridges agent-supplied JSON values and a <see cref="Material"/>'s shader properties for the
|
||||
/// get_material_properties / set_material_properties commands (CLI-213).
|
||||
///
|
||||
/// Value encoding by shader property type:
|
||||
/// - Float / Range / Int : a JSON number
|
||||
/// - Color : <c>[r, g, b, a]</c> (floats 0–1) or a <c>"#RRGGBB"</c>/<c>"#RRGGBBAA"</c> hex string
|
||||
/// - Vector : <c>[x, y, z, w]</c>
|
||||
/// - Texture (TexEnv) : an <see cref="ObjectRef"/> object (resolved + confined to the authoring
|
||||
/// root), or <c>null</c> to clear
|
||||
///
|
||||
/// Reads return the same shapes (Color as <c>[r,g,b,a]</c>, Vector as <c>[x,y,z,w]</c>, Texture as
|
||||
/// <c>{ texture: ObjectRef }</c> or null) so a read round-trips back through a write.
|
||||
/// </summary>
|
||||
internal static class MaterialValueConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Read a single shader property's current value off the material into a plain object suitable
|
||||
/// for JSON serialization. The shape matches the <see cref="ShaderPropertyType"/>.
|
||||
/// </summary>
|
||||
public static object ReadValue(Material material, string propertyName, ShaderPropertyType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case ShaderPropertyType.Color:
|
||||
{
|
||||
var c = material.GetColor(propertyName);
|
||||
return new[] { c.r, c.g, c.b, c.a };
|
||||
}
|
||||
case ShaderPropertyType.Vector:
|
||||
{
|
||||
var v = material.GetVector(propertyName);
|
||||
return new[] { v.x, v.y, v.z, v.w };
|
||||
}
|
||||
case ShaderPropertyType.Float:
|
||||
case ShaderPropertyType.Range:
|
||||
return material.GetFloat(propertyName);
|
||||
case ShaderPropertyType.Int:
|
||||
return material.GetInteger(propertyName);
|
||||
case ShaderPropertyType.Texture:
|
||||
{
|
||||
var tex = material.GetTexture(propertyName);
|
||||
// A re-usable handle for the assigned texture, or null when no texture is bound.
|
||||
var handle = ObjectResolver.Describe(tex);
|
||||
return handle == null ? null : new { texture = handle };
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply a JSON value to a single shader property on the material, converting by the shader's
|
||||
/// declared property type. Throws <see cref="MaterialPropertyTypeMismatch"/> when the supplied
|
||||
/// JSON shape does not match the property's type (so the caller can record it in
|
||||
/// <c>unknown[]</c> with a reason). Texture refs are resolved via <see cref="ObjectResolver"/>
|
||||
/// and confined to the authoring root; an out-of-root or unresolved ref throws
|
||||
/// <see cref="ArgumentException"/> (a hard failure — never silently dropped).
|
||||
/// </summary>
|
||||
public static void ApplyValue(Material material, string propertyName, ShaderPropertyType type, JToken value)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case ShaderPropertyType.Float:
|
||||
material.SetFloat(propertyName, ToFloat(propertyName, "Float", value));
|
||||
break;
|
||||
|
||||
case ShaderPropertyType.Range:
|
||||
material.SetFloat(propertyName, ToFloat(propertyName, "Range", value));
|
||||
break;
|
||||
|
||||
case ShaderPropertyType.Int:
|
||||
// Unity 6: Material.SetInteger is the typed setter for an Int shader property.
|
||||
material.SetInteger(propertyName, ToInt(propertyName, value));
|
||||
break;
|
||||
|
||||
case ShaderPropertyType.Color:
|
||||
material.SetColor(propertyName, ToColor(propertyName, value));
|
||||
break;
|
||||
|
||||
case ShaderPropertyType.Vector:
|
||||
material.SetVector(propertyName, ToVector4(propertyName, value));
|
||||
break;
|
||||
|
||||
case ShaderPropertyType.Texture:
|
||||
material.SetTexture(propertyName, ToTexture(propertyName, value));
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new MaterialPropertyTypeMismatch(
|
||||
$"{propertyName}: unsupported shader property type '{type}'.");
|
||||
}
|
||||
}
|
||||
|
||||
private static float ToFloat(string property, string typeLabel, JToken value)
|
||||
{
|
||||
if (value == null || value.Type == JTokenType.Null)
|
||||
throw new MaterialPropertyTypeMismatch($"{property}: expected {typeLabel}, got null.");
|
||||
if (value.Type != JTokenType.Integer && value.Type != JTokenType.Float)
|
||||
throw new MaterialPropertyTypeMismatch(
|
||||
$"{property}: expected {typeLabel} (a number), got {Describe(value)}.");
|
||||
return value.ToObject<float>();
|
||||
}
|
||||
|
||||
private static int ToInt(string property, JToken value)
|
||||
{
|
||||
if (value == null || value.Type == JTokenType.Null)
|
||||
throw new MaterialPropertyTypeMismatch($"{property}: expected Int, got null.");
|
||||
if (value.Type != JTokenType.Integer && value.Type != JTokenType.Float)
|
||||
throw new MaterialPropertyTypeMismatch(
|
||||
$"{property}: expected Int (a number), got {Describe(value)}.");
|
||||
// Reject fractional JSON numbers (e.g. 1.5) rather than silently rounding/truncating.
|
||||
// An Int shader property must receive a whole number.
|
||||
var asDouble = value.ToObject<double>();
|
||||
if (asDouble != Math.Truncate(asDouble))
|
||||
throw new MaterialPropertyTypeMismatch(
|
||||
$"{property}: expected Int (a whole number), got the non-integer value {value}.");
|
||||
return value.ToObject<int>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a Color from either <c>[r,g,b,a]</c> (3 or 4 floats) or a hex string
|
||||
/// (<c>"#RRGGBB"</c> / <c>"#RRGGBBAA"</c>, leading '#' optional). Default alpha is 1.0.
|
||||
/// </summary>
|
||||
private static Color ToColor(string property, JToken value)
|
||||
{
|
||||
if (value is JArray arr)
|
||||
{
|
||||
if (arr.Count != 3 && arr.Count != 4)
|
||||
throw new MaterialPropertyTypeMismatch(
|
||||
$"{property}: expected Color as [r,g,b,a] (or [r,g,b]), got an array of length {arr.Count}.");
|
||||
// Route each component through ToFloat so non-numeric/null elements surface as a clean
|
||||
// MaterialPropertyTypeMismatch rather than throwing and failing the whole command.
|
||||
float r = ToFloat(property, "Color", arr[0]);
|
||||
float g = ToFloat(property, "Color", arr[1]);
|
||||
float b = ToFloat(property, "Color", arr[2]);
|
||||
float a = arr.Count == 4 ? ToFloat(property, "Color", arr[3]) : 1f;
|
||||
return new Color(r, g, b, a);
|
||||
}
|
||||
|
||||
if (value != null && value.Type == JTokenType.String)
|
||||
{
|
||||
if (TryParseHexColor(value.ToObject<string>(), out var color))
|
||||
return color;
|
||||
throw new MaterialPropertyTypeMismatch(
|
||||
$"{property}: expected Color as [r,g,b,a] or a '#RRGGBB'/'#RRGGBBAA' hex string, got '{value}'.");
|
||||
}
|
||||
|
||||
throw new MaterialPropertyTypeMismatch(
|
||||
$"{property}: expected Color as [r,g,b,a] or a hex string, got {Describe(value)}.");
|
||||
}
|
||||
|
||||
private static Vector4 ToVector4(string property, JToken value)
|
||||
{
|
||||
if (!(value is JArray arr))
|
||||
throw new MaterialPropertyTypeMismatch(
|
||||
$"{property}: expected Vector as [x,y,z,w], got {Describe(value)}.");
|
||||
if (arr.Count == 0 || arr.Count > 4)
|
||||
throw new MaterialPropertyTypeMismatch(
|
||||
$"{property}: expected Vector as [x,y,z,w] (1–4 numbers), got an array of length {arr.Count}.");
|
||||
|
||||
// Missing trailing components default to 0 (matches Unity's Vector4 component default),
|
||||
// so [x,y,z] and [x,y] are accepted as shorthand for a 4-component vector.
|
||||
// Route each present component through ToFloat so non-numeric/null elements surface as a
|
||||
// clean MaterialPropertyTypeMismatch rather than throwing and failing the whole command.
|
||||
float x = arr.Count > 0 ? ToFloat(property, "Vector", arr[0]) : 0f;
|
||||
float y = arr.Count > 1 ? ToFloat(property, "Vector", arr[1]) : 0f;
|
||||
float z = arr.Count > 2 ? ToFloat(property, "Vector", arr[2]) : 0f;
|
||||
float w = arr.Count > 3 ? ToFloat(property, "Vector", arr[3]) : 0f;
|
||||
return new Vector4(x, y, z, w);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a texture property value: an explicit <c>null</c> clears it, otherwise the value must
|
||||
/// be an <see cref="ObjectRef"/> that resolves to a <see cref="Texture"/> confined to the
|
||||
/// authoring root. A non-texture object, an out-of-root asset, or an unresolved handle is a hard
|
||||
/// failure (never silently dropped).
|
||||
/// </summary>
|
||||
private static Texture ToTexture(string property, JToken value)
|
||||
{
|
||||
if (value == null || value.Type == JTokenType.Null)
|
||||
return null;
|
||||
|
||||
// ReadValue returns textures as { "texture": <ObjectRef> } so a get->set round-trip works.
|
||||
// Accept both that wrapper form and a bare ObjectRef supplied directly by the caller.
|
||||
JToken refToken = value;
|
||||
if (value is JObject wrapper && wrapper.ContainsKey("texture"))
|
||||
refToken = wrapper["texture"];
|
||||
|
||||
ObjectRef handle;
|
||||
try
|
||||
{
|
||||
handle = refToken.ToObject<ObjectRef>();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
throw new MaterialPropertyTypeMismatch(
|
||||
$"{property}: expected Texture as an object reference {{guid/path/...}} or null, got {Describe(value)}.");
|
||||
}
|
||||
|
||||
if (handle == null || handle.IsEmpty)
|
||||
throw new MaterialPropertyTypeMismatch(
|
||||
$"{property}: expected Texture as an object reference {{guid/path/...}} or null, got {Describe(value)}.");
|
||||
|
||||
if (!ObjectResolver.TryResolve(handle, out var obj, out var error))
|
||||
throw new ArgumentException($"{property}: could not resolve texture reference: {error}");
|
||||
|
||||
if (!(obj is Texture texture))
|
||||
throw new MaterialPropertyTypeMismatch(
|
||||
$"{property}: expected Texture, got a {obj.GetType().Name}.");
|
||||
|
||||
// Confine to the authoring root: a GUID/globalId/path handle must bind a texture that lives
|
||||
// on disk under the sandbox. An in-memory/scene Texture has an empty asset path; allowing it
|
||||
// would bypass the root confinement, so reject any ref that doesn't resolve to a persisted
|
||||
// asset. Mirrors AssetCommands.ResolveAssetPath's confinement.
|
||||
var assetPath = AssetDatabase.GetAssetPath(texture);
|
||||
if (string.IsNullOrEmpty(assetPath))
|
||||
throw new ArgumentException(
|
||||
$"{property}: texture reference does not resolve to an on-disk asset under the authoring root '{ProjectPaths.AuthoringRoot}'; in-memory or scene textures are not allowed.");
|
||||
|
||||
var confined = ProjectPaths.Resolve(assetPath, out var confineError);
|
||||
if (confined == null)
|
||||
throw new ArgumentException(
|
||||
$"{property}: texture '{assetPath}' is outside the authoring root '{ProjectPaths.AuthoringRoot}': {confineError}");
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a hex color string ("#RRGGBB", "#RRGGBBAA", or without the leading '#'). Returns false
|
||||
/// for any malformed input. Channels are 0–255 mapped to 0–1; default alpha is 1.0.
|
||||
/// </summary>
|
||||
public static bool TryParseHexColor(string hex, out Color color)
|
||||
{
|
||||
color = Color.white;
|
||||
if (string.IsNullOrWhiteSpace(hex))
|
||||
return false;
|
||||
|
||||
var s = hex.Trim();
|
||||
if (s.StartsWith("#", StringComparison.Ordinal))
|
||||
s = s.Substring(1);
|
||||
|
||||
if (s.Length != 6 && s.Length != 8)
|
||||
return false;
|
||||
|
||||
if (!byte.TryParse(s.Substring(0, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var r) ||
|
||||
!byte.TryParse(s.Substring(2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var g) ||
|
||||
!byte.TryParse(s.Substring(4, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b))
|
||||
return false;
|
||||
|
||||
byte a = 255;
|
||||
if (s.Length == 8 &&
|
||||
!byte.TryParse(s.Substring(6, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out a))
|
||||
return false;
|
||||
|
||||
color = new Color(r / 255f, g / 255f, b / 255f, a / 255f);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Map a <see cref="UnityEngine.Rendering.ShaderPropertyType"/> to the public type label used in results.</summary>
|
||||
public static string TypeLabel(ShaderPropertyType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case ShaderPropertyType.Color: return "Color";
|
||||
case ShaderPropertyType.Vector: return "Vector";
|
||||
case ShaderPropertyType.Float: return "Float";
|
||||
case ShaderPropertyType.Range: return "Range";
|
||||
case ShaderPropertyType.Texture: return "TexEnv";
|
||||
case ShaderPropertyType.Int: return "Int";
|
||||
default: return type.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
private static string Describe(JToken value)
|
||||
{
|
||||
if (value == null)
|
||||
return "null";
|
||||
switch (value.Type)
|
||||
{
|
||||
case JTokenType.Array: return "array";
|
||||
case JTokenType.Object: return "object";
|
||||
case JTokenType.Null: return "null";
|
||||
default: return value.Type.ToString().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a supplied value's JSON shape does not match the shader property's declared type.
|
||||
/// The command catches this and records the property in <c>unknown[]</c> with the message as the
|
||||
/// reason, rather than failing the whole call.
|
||||
/// </summary>
|
||||
internal sealed class MaterialPropertyTypeMismatch : Exception
|
||||
{
|
||||
public MaterialPropertyTypeMismatch(string message) : base(message) { }
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fc8956be3c264ccb8096787d7146f330
|
||||
@@ -0,0 +1,188 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Materials
|
||||
{
|
||||
/// <summary>
|
||||
/// Shader discovery / introspection commands (CLI-213): <c>list_shaders</c> so an agent can pick a
|
||||
/// valid shader name, and <c>get_shader_properties</c> so it knows a shader's settable property
|
||||
/// names / types / ranges / texture dimensions / flags before authoring a material.
|
||||
///
|
||||
/// Property introspection uses <see cref="Shader"/> instance APIs exclusively:
|
||||
/// <c>GetPropertyCount</c>, <c>GetPropertyName</c>, <c>GetPropertyType</c>,
|
||||
/// <c>GetPropertyDescription</c>, <c>GetPropertyRangeLimits</c>,
|
||||
/// <c>GetPropertyTextureDimension</c>, and <c>GetPropertyFlags</c>. The reported type set is
|
||||
/// <see cref="UnityEngine.Rendering.ShaderPropertyType"/>: Color, Vector, Float, Range, Texture, Int.
|
||||
/// </summary>
|
||||
static class ShaderCommands
|
||||
{
|
||||
[CliCommand("list_shaders",
|
||||
"Discover available shaders so an agent can pick a valid name for set_material_properties / create_asset. Returns [{ name, assetPath|null, isBuiltin, isSupported }].",
|
||||
Tags = new[] { "materials/shaders" })]
|
||||
public static List<ShaderInfo> ListShaders(
|
||||
[CliArg("filter", "Case-insensitive substring matched against the shader name (e.g. \"URP\", \"Lit\").")] string filter = null,
|
||||
[CliArg("includeBuiltin", "Include built-in/engine shaders (those with no project asset path). Default true.")] bool includeBuiltin = true,
|
||||
[CliArg("limit", "Maximum number of shaders to return (default 200).")] int limit = 200)
|
||||
{
|
||||
if (limit <= 0)
|
||||
limit = 200;
|
||||
|
||||
var results = new List<ShaderInfo>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
// AssetDatabase.FindAssets("t:Shader") enumerates project + package shaders (each with an
|
||||
// asset path); ShaderUtil.GetAllShaderInfo() additionally surfaces built-in engine shaders
|
||||
// that have no asset path (e.g. "Standard", "Sprites/Default"). Merge both, dedup by name.
|
||||
foreach (var guid in AssetDatabase.FindAssets("t:Shader"))
|
||||
{
|
||||
var path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
if (string.IsNullOrEmpty(path))
|
||||
continue;
|
||||
var shader = AssetDatabase.LoadAssetAtPath<Shader>(path);
|
||||
if (shader == null)
|
||||
continue;
|
||||
|
||||
if (!seen.Add(shader.name))
|
||||
continue;
|
||||
|
||||
if (!PassesFilter(shader.name, filter))
|
||||
continue;
|
||||
|
||||
results.Add(new ShaderInfo
|
||||
{
|
||||
Name = shader.name,
|
||||
AssetPath = path,
|
||||
IsBuiltin = false,
|
||||
IsSupported = shader.isSupported,
|
||||
});
|
||||
|
||||
if (results.Count >= limit)
|
||||
return results;
|
||||
}
|
||||
|
||||
if (includeBuiltin)
|
||||
{
|
||||
// GetAllShaderInfo() enumerates every shader the editor knows about, including built-in
|
||||
// engine shaders that have no project/package asset path. Any name NOT already added by
|
||||
// the FindAssets("t:Shader") pass above is a built-in (assetPath null). `info.supported`
|
||||
// already reflects Shader::IsSupported(), so we don't re-run Shader.Find per entry.
|
||||
foreach (var info in ShaderUtil.GetAllShaderInfo())
|
||||
{
|
||||
if (!seen.Add(info.name))
|
||||
continue;
|
||||
|
||||
if (!PassesFilter(info.name, filter))
|
||||
continue;
|
||||
|
||||
results.Add(new ShaderInfo
|
||||
{
|
||||
Name = info.name,
|
||||
AssetPath = null,
|
||||
IsBuiltin = true,
|
||||
IsSupported = info.supported,
|
||||
});
|
||||
|
||||
if (results.Count >= limit)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
[CliCommand("get_shader_properties",
|
||||
"Introspect a shader's declared property list (name, description, type Color|Vector|Float|Range|TexEnv|Int, range, textureDimension, flags). Provide 'shader' (by name) OR 'material' (read the shader off that material).",
|
||||
Tags = new[] { "materials/shaders" })]
|
||||
public static ShaderPropertiesResult GetShaderProperties(
|
||||
[CliArg("shader", "Shader name (e.g. \"Universal Render Pipeline/Lit\"). Provide this OR 'material'.")] string shader = null,
|
||||
[CliArg("material", "Reference to a material to read the shader from instead of naming it. Provide this OR 'shader'.")] ObjectRef material = null)
|
||||
{
|
||||
Shader resolved;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(shader))
|
||||
{
|
||||
resolved = Shader.Find(shader);
|
||||
if (resolved == null)
|
||||
throw new ShaderNotFoundException(
|
||||
$"Shader '{shader}' not found. Shader.Find only resolves shaders that are compiled/included in the build (or referenced by a material/scene). Use list_shaders to discover valid names.");
|
||||
}
|
||||
else if (material != null && !material.IsEmpty)
|
||||
{
|
||||
var mat = MaterialCommands.ResolveMaterial(material, out _);
|
||||
resolved = mat.shader;
|
||||
if (resolved == null)
|
||||
throw new ArgumentException($"Material '{material}' has no shader assigned.");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ArgumentException("Provide either 'shader' (a shader name) or 'material' (a material reference).");
|
||||
}
|
||||
|
||||
var result = new ShaderPropertiesResult { Shader = resolved.name };
|
||||
|
||||
int count = resolved.GetPropertyCount();
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var propType = resolved.GetPropertyType(i);
|
||||
var entry = new ShaderPropertyInfo
|
||||
{
|
||||
Name = resolved.GetPropertyName(i),
|
||||
Description = resolved.GetPropertyDescription(i),
|
||||
Type = MaterialValueConverter.TypeLabel(propType),
|
||||
Flags = DescribeFlags(resolved.GetPropertyFlags(i)),
|
||||
};
|
||||
|
||||
if (propType == ShaderPropertyType.Range)
|
||||
{
|
||||
var limits = resolved.GetPropertyRangeLimits(i);
|
||||
entry.Range = new MaterialRange
|
||||
{
|
||||
Min = limits.x,
|
||||
Max = limits.y,
|
||||
};
|
||||
}
|
||||
|
||||
if (propType == ShaderPropertyType.Texture)
|
||||
entry.TextureDimension = resolved.GetPropertyTextureDimension(i).ToString();
|
||||
|
||||
result.Properties.Add(entry);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool PassesFilter(string name, string filter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filter))
|
||||
return true;
|
||||
return name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompose a <see cref="ShaderPropertyFlags"/> bitmask into its individual flag names (the
|
||||
/// enum is a [Flags] type). <c>None</c> is reported as an empty list rather than ["None"].
|
||||
/// </summary>
|
||||
private static List<string> DescribeFlags(ShaderPropertyFlags flags)
|
||||
{
|
||||
var list = new List<string>();
|
||||
if (flags == ShaderPropertyFlags.None)
|
||||
return list;
|
||||
|
||||
foreach (ShaderPropertyFlags flag in Enum.GetValues(typeof(ShaderPropertyFlags)))
|
||||
{
|
||||
if (flag == ShaderPropertyFlags.None)
|
||||
continue;
|
||||
if ((flags & flag) == flag)
|
||||
list.Add(flag.ToString());
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1007e4472d7b4216a6a866f8aaba76c7
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user