Add Unity Pipeline package support
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 881bcacc155b44229761a2e2b2917fae
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+423
@@ -0,0 +1,423 @@
|
||||
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>
|
||||
public 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)]
|
||||
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)]
|
||||
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)]
|
||||
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)]
|
||||
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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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
|
||||
+885
@@ -0,0 +1,885 @@
|
||||
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>
|
||||
public static class AnimatorControllerCommands
|
||||
{
|
||||
[CliCommand("create_animator_controller",
|
||||
"Create an .controller AnimatorController asset (with a default Base Layer) under the authoring root.",
|
||||
MainThreadRequired = true)]
|
||||
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)]
|
||||
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)]
|
||||
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)]
|
||||
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)]
|
||||
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)]
|
||||
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]
|
||||
public 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]
|
||||
public class ParameterInfo
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("type")]
|
||||
public string ParamType { get; set; }
|
||||
|
||||
[JsonProperty("defaultValue")]
|
||||
public JToken DefaultValue { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AddParameterResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("parameter")]
|
||||
public ParameterInfo Parameter { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class LayerSummary
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("index")]
|
||||
public int Index { get; set; }
|
||||
|
||||
[JsonProperty("weight")]
|
||||
public float Weight { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AddLayerResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("layer")]
|
||||
public LayerSummary Layer { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public 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]
|
||||
public class AddStateResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("state")]
|
||||
public StateSummary State { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class TransitionSummary
|
||||
{
|
||||
[JsonProperty("from")]
|
||||
public string From { get; set; }
|
||||
|
||||
[JsonProperty("to")]
|
||||
public string To { get; set; }
|
||||
|
||||
[JsonProperty("conditionCount")]
|
||||
public int ConditionCount { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AddTransitionResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("transition")]
|
||||
public TransitionSummary Transition { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public 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]
|
||||
public 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]
|
||||
public class StateInfo
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("motion")]
|
||||
public string Motion { get; set; }
|
||||
|
||||
[JsonProperty("isDefault")]
|
||||
public bool IsDefault { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public 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]
|
||||
public 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
|
||||
+637
@@ -0,0 +1,637 @@
|
||||
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>
|
||||
public 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)]
|
||||
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)]
|
||||
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)]
|
||||
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)]
|
||||
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]
|
||||
public class TimelineTrackSummary
|
||||
{
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
|
||||
[JsonProperty("type")]
|
||||
public string TrackType { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AddTimelineTrackResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("track")]
|
||||
public TimelineTrackSummary Track { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class TimelineClipSummary
|
||||
{
|
||||
[JsonProperty("track")]
|
||||
public string Track { get; set; }
|
||||
|
||||
[JsonProperty("start")]
|
||||
public float Start { get; set; }
|
||||
|
||||
[JsonProperty("duration")]
|
||||
public float Duration { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AddTimelineClipResult : AuthoringResult
|
||||
{
|
||||
[JsonProperty("clip")]
|
||||
public TimelineClipSummary Clip { get; set; }
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
|
||||
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>
|
||||
public 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");
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f3f8120d06814b2c86d46503ac132022
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 39163f849a3aebd47a3f718fdc0224f9
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+602
@@ -0,0 +1,602 @@
|
||||
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 PipelinePhysicsMaterial = UnityEngine.PhysicsMaterial;
|
||||
#else
|
||||
using PipelinePhysicsMaterial = 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>
|
||||
public 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.")]
|
||||
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(PipelinePhysicsMaterial).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(PipelinePhysicsMaterial).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 PipelinePhysicsMaterial
|
||||
{
|
||||
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.")]
|
||||
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.")]
|
||||
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.")]
|
||||
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).")]
|
||||
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.")]
|
||||
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.")]
|
||||
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;
|
||||
|
||||
var physicsMaterialType = ResolvePhysicsMaterialTypeName(typeName);
|
||||
if (physicsMaterialType != null)
|
||||
return physicsMaterialType;
|
||||
|
||||
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(PipelinePhysicsMaterial);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private static Type ResolvePhysicsMaterialTypeName(string typeName)
|
||||
{
|
||||
if (typeName == "PhysicsMaterial" || typeName == "PhysicMaterial"
|
||||
|| typeName == "UnityEngine.PhysicsMaterial" || typeName == "UnityEngine.PhysicMaterial")
|
||||
{
|
||||
return typeof(PipelinePhysicsMaterial);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 47b49bf20465486fb6ffdf6c03775b16
|
||||
+654
@@ -0,0 +1,654 @@
|
||||
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;
|
||||
|
||||
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>
|
||||
public 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.")]
|
||||
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.")]
|
||||
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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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
|
||||
+34
@@ -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]
|
||||
public 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>();
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7ff032cead554b7f97cd2467a83abd35
|
||||
+51
@@ -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>
|
||||
public static class FolderCommands
|
||||
{
|
||||
[CliCommand("create_folder", "Create a folder under the authoring root (creates intermediate folders).")]
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b78420627d9cd1e43835d9088975e691
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+126
@@ -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>
|
||||
public static class TextFileCommands
|
||||
{
|
||||
[CliCommand("read_text_file", "Read a UTF-8 text file under the authoring root and return its contents.", MainThreadRequired = true)]
|
||||
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.")]
|
||||
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]
|
||||
public class ReadTextFileResult
|
||||
{
|
||||
[JsonProperty("assetPath")]
|
||||
public string AssetPath { get; set; }
|
||||
|
||||
[JsonProperty("bytes")]
|
||||
public int Bytes { get; set; }
|
||||
|
||||
[JsonProperty("contents")]
|
||||
public string Contents { get; set; }
|
||||
}
|
||||
}
|
||||
+2
@@ -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>
|
||||
public static class AuthoringConfigCommands
|
||||
{
|
||||
[CliCommand("get_authoring_root", "Get the base folder (under Assets/) that bare authoring paths resolve against.")]
|
||||
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.")]
|
||||
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,87 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
|
||||
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: state is static and resets on domain reload, so auto-tick turns itself off after a
|
||||
/// recompile; re-enable it if needed. Forcing full-rate ticks uses CPU like a focused editor.
|
||||
/// </summary>
|
||||
public static class AutoTickCommand
|
||||
{
|
||||
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;
|
||||
|
||||
[CliCommand("set_autotick", "Keep the editor ticking while unfocused by forcing EditorApplication.SignalTick at a throttled rate", MainThreadRequired = true)]
|
||||
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 = 16)
|
||||
{
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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:
|
||||
+42
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cf05be2261cb475c9cf3d7fccbf65ff5
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+640
@@ -0,0 +1,640 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
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]
|
||||
public 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.")]
|
||||
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)]
|
||||
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()).")]
|
||||
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.")]
|
||||
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.).")]
|
||||
public static LightingSettingsResult GetLightingSettings()
|
||||
{
|
||||
return ReadLightingSettings();
|
||||
}
|
||||
|
||||
[CliCommand("set_lighting_settings", "Apply a subset of lighting settings to the active LightingSettings. Returns { applied[], unknown[] }.")]
|
||||
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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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:
|
||||
+424
@@ -0,0 +1,424 @@
|
||||
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;
|
||||
|
||||
// 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]
|
||||
public 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.")]
|
||||
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)]
|
||||
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()).")]
|
||||
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.")]
|
||||
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).")]
|
||||
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[] }.")]
|
||||
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.")]
|
||||
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]
|
||||
public 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]
|
||||
public 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:
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
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]
|
||||
public 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.")]
|
||||
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)]
|
||||
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()).")]
|
||||
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.")]
|
||||
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]
|
||||
public 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]
|
||||
public 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: bdb8ef5b0513b0d488ac3be1a9a77185
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+577
@@ -0,0 +1,577 @@
|
||||
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;
|
||||
|
||||
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]
|
||||
public 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;
|
||||
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)]
|
||||
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)]
|
||||
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
|
||||
};
|
||||
|
||||
var report = BuildPipeline.BuildPlayer(buildOptions);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e718f193c77f724ea4de47fe8a2a3cb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
using UnityEngine;
|
||||
|
||||
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>
|
||||
public static class BuildConfigCommands
|
||||
{
|
||||
[CliCommand("list_build_targets",
|
||||
"List the known BuildTarget values with their group and whether build support is installed.",
|
||||
MainThreadRequired = true)]
|
||||
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)]
|
||||
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)]
|
||||
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)]
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e65c0b78842b492d8f9363ec749bc370
|
||||
+271
@@ -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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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>
|
||||
public 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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 37afada24be667e489d7e90a62f2ca1d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
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]
|
||||
public 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)]
|
||||
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)]
|
||||
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:
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
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 or the Scene View to a PNG and return it
|
||||
/// base64-encoded, so an agent can "see" the editor without a display. 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>
|
||||
public static class CaptureCommands
|
||||
{
|
||||
private const int MaxDimension = 4096;
|
||||
|
||||
[CliCommand("capture_game_view", "Render a camera to a PNG. Returns it inline as base64, unless save_path is set (path-only result; pass include_inline_image=true to get both).")]
|
||||
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; 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)
|
||||
{
|
||||
GuardHasGpu();
|
||||
|
||||
var cam = ResolveCamera(camera);
|
||||
if (cam == null)
|
||||
throw new ArgumentException("No camera found to capture.");
|
||||
|
||||
return Capture(cam, width, height, $"camera:{cam.name}", savePath, includeInlineImage, maxResolution);
|
||||
}
|
||||
|
||||
[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).")]
|
||||
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.");
|
||||
|
||||
return Capture(sv.camera, width, height, "sceneView", savePath, includeInlineImage, maxResolution);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared capture core. Renders at the requested (clamped) size 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.
|
||||
/// </summary>
|
||||
private static CaptureResult Capture(Camera cam, int width, int height, string source,
|
||||
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 = EncodeCameraToPng(cam, 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 = EncodeCameraToPng(cam, 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>
|
||||
/// 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]
|
||||
public 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; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3247a78c799021c4094dd3f7e3717aeb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
#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>
|
||||
public 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)]
|
||||
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:
|
||||
+63
@@ -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>
|
||||
public static class EditorStatusCommand
|
||||
{
|
||||
[CliCommand("editor_status", "Get detailed Unity Editor status and state information")]
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 953fb97ec3dd48e4aa2ed9607c412f44
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
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>
|
||||
public 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)]
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -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:
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
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>
|
||||
public 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.")]
|
||||
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.")]
|
||||
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.")]
|
||||
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.")]
|
||||
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>
|
||||
public 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>
|
||||
public 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>
|
||||
public 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
|
||||
+533
@@ -0,0 +1,533 @@
|
||||
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>
|
||||
public 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.")]
|
||||
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.")]
|
||||
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.")]
|
||||
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.")]
|
||||
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.")]
|
||||
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).")]
|
||||
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).")]
|
||||
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).")]
|
||||
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.")]
|
||||
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).")]
|
||||
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
|
||||
+82
@@ -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>
|
||||
public 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f033287d429f4957bfcfd6795f731b48
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4841c84bba2a471eb3fb429f79e3b118
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+405
@@ -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>
|
||||
public 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).")]
|
||||
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).
|
||||
RenderQueue = GetRawRenderQueue(mat),
|
||||
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[].")]
|
||||
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;
|
||||
}
|
||||
|
||||
private static int GetRawRenderQueue(Material mat)
|
||||
{
|
||||
var serialized = new SerializedObject(mat);
|
||||
var customRenderQueue = serialized.FindProperty("m_CustomRenderQueue");
|
||||
return customRenderQueue != null ? customRenderQueue.intValue : mat.renderQueue;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public 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
|
||||
+147
@@ -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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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]
|
||||
public 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>();
|
||||
}
|
||||
}
|
||||
+2
@@ -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
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
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>
|
||||
public 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 }].")]
|
||||
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).")]
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1007e4472d7b4216a6a866f8aaba76c7
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Executes an Editor menu item by its path (e.g. "Assets/Reimport All"), or — when called with
|
||||
/// no path — lists the available menu items. Backs the <c>unity menu</c> CLI command (CLI-110):
|
||||
/// <c>unity menu</c> lists options, <c>unity menu "<path>"</c> executes one. The CLI only
|
||||
/// forwards the request; all logic lives here, per the two-commands design.
|
||||
///
|
||||
/// Named <c>MenuItemCommand</c> (not <c>MenuCommand</c>) to avoid colliding with the built-in
|
||||
/// <see cref="UnityEditor.MenuCommand"/> type, which would make unqualified references ambiguous
|
||||
/// in any file that also imports <c>UnityEditor</c>.
|
||||
///
|
||||
/// Execution wraps <see cref="EditorApplication.ExecuteMenuItem"/>, which returns <c>false</c>
|
||||
/// when the item could not be invoked — either because the path does not exist or because the
|
||||
/// item is currently disabled (its validation function returned false). The API does not
|
||||
/// distinguish those cases, so a failure is reported as "not found or disabled"; the CLI maps a
|
||||
/// failed result to its "menu item does not exist" exit code.
|
||||
///
|
||||
/// Listing reflects the <em>actual</em> Editor menu via the internal
|
||||
/// <c>UnityEditor.Menu.GetMenuItems</c> API (accessed by reflection, mirroring
|
||||
/// <see cref="FocusEditorCommand"/>). Unlike scanning <c>[MenuItem]</c> attributes, this also
|
||||
/// covers menus Unity defines natively (much of <c>GameObject/…</c>, <c>Assets/Create/…</c>,
|
||||
/// <c>File/…</c>, etc.), which carry no attribute and would otherwise be missed.
|
||||
///
|
||||
/// Note: some menu items are heavy, show a modal confirmation, or trigger a domain reload/quit
|
||||
/// (which tears the server down before it can reply). That is the nature of the invoked item;
|
||||
/// such cases are a known limitation of driving arbitrary menus over a request/response call.
|
||||
/// </summary>
|
||||
public static class MenuItemCommand
|
||||
{
|
||||
[CliCommand("menu", "Execute an Editor menu item by path, or list available items when no path is given", MainThreadRequired = true)]
|
||||
public static MenuResponse ExecuteMenu(
|
||||
[CliArg("path", "Menu item path to execute, e.g. \"Assets/Reimport All\". Omit to list available menu items.")] string path = "")
|
||||
{
|
||||
// No path → discovery mode: return the available menu items instead of executing.
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
List<string> items;
|
||||
try
|
||||
{
|
||||
items = DiscoverMenuItems();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return MenuResponse.Fail(null, $"Failed to list menu items: {ex.Message}");
|
||||
}
|
||||
|
||||
return new MenuResponse
|
||||
{
|
||||
Success = true,
|
||||
Items = items,
|
||||
Message = $"{items.Count} menu item(s) available."
|
||||
};
|
||||
}
|
||||
|
||||
bool executed;
|
||||
try
|
||||
{
|
||||
executed = EditorApplication.ExecuteMenuItem(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return MenuResponse.Fail(path, $"Failed to execute menu item '{path}': {ex.Message}");
|
||||
}
|
||||
|
||||
if (!executed)
|
||||
return MenuResponse.Fail(path, $"Menu item '{path}' was not found or is currently disabled.");
|
||||
|
||||
return new MenuResponse
|
||||
{
|
||||
Success = true,
|
||||
Path = path,
|
||||
Message = $"Executed menu item '{path}'"
|
||||
};
|
||||
}
|
||||
|
||||
const BindingFlags k_All = BindingFlags.Static | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
|
||||
|
||||
// Reflection handles for the internal UnityEditor.Menu.GetMenuItems(string, bool, bool) API,
|
||||
// which returns an array of internal UnityEditor.ScriptingMenuItem. Resolved once and cached;
|
||||
// we read each item's `path` and `isSeparator` member (property or field, depending on the
|
||||
// Unity version). See FocusEditorCommand for the same reflect-into-internal-API approach.
|
||||
static MethodInfo s_GetMenuItems;
|
||||
static Func<object, string> s_GetPath;
|
||||
static Func<object, bool> s_GetIsSeparator;
|
||||
static bool s_Resolved;
|
||||
|
||||
static void EnsureReflection()
|
||||
{
|
||||
if (s_Resolved)
|
||||
return;
|
||||
s_Resolved = true;
|
||||
|
||||
var editorAsm = typeof(EditorApplication).Assembly;
|
||||
|
||||
var menuType = editorAsm.GetType("UnityEditor.Menu");
|
||||
s_GetMenuItems = menuType?.GetMethod(
|
||||
"GetMenuItems", k_All, null, new[] { typeof(string), typeof(bool), typeof(bool) }, null);
|
||||
|
||||
var itemType = editorAsm.GetType("UnityEditor.ScriptingMenuItem");
|
||||
s_GetPath = BuildAccessor<string>(itemType, "path", "m_Path");
|
||||
s_GetIsSeparator = BuildAccessor<bool>(itemType, "isSeparator", "m_IsSeparator");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a getter for a member that may be exposed as a property or a backing field, so the
|
||||
/// reflection survives the internal type's shape changing across Unity versions.
|
||||
/// </summary>
|
||||
static Func<object, T> BuildAccessor<T>(Type type, string propertyName, string fieldName)
|
||||
{
|
||||
var prop = type?.GetProperty(propertyName, k_All);
|
||||
if (prop != null)
|
||||
return o => (T)prop.GetValue(o);
|
||||
|
||||
var field = type?.GetField(propertyName, k_All) ?? type?.GetField(fieldName, k_All);
|
||||
if (field != null)
|
||||
return o => (T)field.GetValue(o);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Standard top-level Editor menus. Unity defines these natively (no [MenuItem]), so they are
|
||||
// not discoverable from attributes; we seed them so their native children are enumerated.
|
||||
static readonly string[] k_NativeRoots =
|
||||
{
|
||||
"File", "Edit", "Assets", "GameObject", "Component", "Window", "Help"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Collect the distinct menu paths from the live Editor menu. <c>Menu.GetMenuItems</c> returns
|
||||
/// the full (recursive) set of items under a given root — including ones Unity defines
|
||||
/// natively — but it does not enumerate the roots themselves, and no internal API does. So we
|
||||
/// union the native roots above with the first path segment of every <c>[MenuItem]</c> (which
|
||||
/// surfaces custom/package roots), then expand each root through the internal API. The items
|
||||
/// always come from the live menu, so native entries are included.
|
||||
/// </summary>
|
||||
static List<string> DiscoverMenuItems()
|
||||
{
|
||||
EnsureReflection();
|
||||
|
||||
if (s_GetMenuItems == null || s_GetPath == null)
|
||||
throw new InvalidOperationException(
|
||||
"UnityEditor.Menu.GetMenuItems is unavailable in this Unity version.");
|
||||
|
||||
var paths = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var root in CollectRoots())
|
||||
CollectItemsUnder(root, paths);
|
||||
|
||||
return paths.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The top-level menu names to expand: the native roots, plus the first segment of every
|
||||
/// attributed menu item (covering custom/package menus). Component context menus
|
||||
/// (<c>CONTEXT/…</c>) are excluded, as they are not part of the main menu bar.
|
||||
/// </summary>
|
||||
static IEnumerable<string> CollectRoots()
|
||||
{
|
||||
var roots = new SortedSet<string>(StringComparer.Ordinal);
|
||||
foreach (var r in k_NativeRoots)
|
||||
roots.Add(r);
|
||||
|
||||
foreach (var method in TypeCache.GetMethodsWithAttribute<MenuItem>())
|
||||
{
|
||||
foreach (var attr in method.GetCustomAttributes(typeof(MenuItem), false).Cast<MenuItem>())
|
||||
{
|
||||
if (attr.validate || string.IsNullOrEmpty(attr.menuItem))
|
||||
continue;
|
||||
|
||||
var slash = attr.menuItem.IndexOf('/');
|
||||
var root = slash > 0 ? attr.menuItem.Substring(0, slash) : attr.menuItem;
|
||||
if (root.StartsWith("CONTEXT", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
roots.Add(root);
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append every non-separator menu path under <paramref name="root"/> to
|
||||
/// <paramref name="paths"/>. A root that isn't present in this Editor simply contributes
|
||||
/// nothing (the API returns an empty array).
|
||||
/// </summary>
|
||||
static void CollectItemsUnder(string root, SortedSet<string> paths)
|
||||
{
|
||||
if (!(s_GetMenuItems.Invoke(null, new object[] { root, false, false }) is Array items))
|
||||
return;
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item == null)
|
||||
continue;
|
||||
if (s_GetIsSeparator != null && s_GetIsSeparator(item))
|
||||
continue;
|
||||
|
||||
var path = s_GetPath(item);
|
||||
if (string.IsNullOrEmpty(path))
|
||||
continue;
|
||||
|
||||
paths.Add(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of the <c>menu</c> command. On execution, <see cref="Success"/> false means the item
|
||||
/// could not be invoked (not found or disabled). In list mode, <see cref="Items"/> holds the
|
||||
/// available menu paths.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class MenuResponse
|
||||
{
|
||||
[JsonProperty("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[JsonProperty("path")]
|
||||
public string Path { get; set; }
|
||||
|
||||
[JsonProperty("items")]
|
||||
public List<string> Items { get; set; }
|
||||
|
||||
[JsonProperty("message")]
|
||||
public string Message { get; set; }
|
||||
|
||||
public static MenuResponse Fail(string path, string message) => new MenuResponse
|
||||
{
|
||||
Success = false,
|
||||
Path = path,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f48719fc11d44809a97c890b8317c0d7
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 31cd60e0286e77f4cb5baaaa7a3d6377
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Search;
|
||||
using UnityEngine;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Navigation
|
||||
{
|
||||
/// <summary>
|
||||
/// Navigation and targeting commands (CLI-200): read and drive the Editor selection, and run
|
||||
/// Unity Search queries — both returning the canonical <see cref="AuthoringResult"/> object
|
||||
/// identity (via <see cref="ObjectResolver.Describe"/>) so an agent can reference results in a
|
||||
/// follow-up call. All commands are read-only or non-destructive (set_selection only changes the
|
||||
/// Editor selection, which carries no undo/safety policy), and run on the main thread.
|
||||
/// </summary>
|
||||
public static class NavigationCommands
|
||||
{
|
||||
private const int MaxSearchResults = 200;
|
||||
|
||||
[CliCommand("get_selection", "Read the current Editor selection as structured object identities.")]
|
||||
public static SelectionResult GetSelection()
|
||||
{
|
||||
return DescribeSelection(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <para>Set the Editor selection from instance ids and/or asset paths.</para>
|
||||
/// <para>
|
||||
/// We accept simple <paramref name="instanceIds"/> / <paramref name="paths"/> rather than full
|
||||
/// <see cref="ObjectRef"/>[] handles because nested-object parameter schemas (arrays of objects)
|
||||
/// are pending CAT-2508. Once that lands this can take an <see cref="ObjectRef"/>[] and route
|
||||
/// each through <see cref="ObjectResolver.TryResolve"/> for the full handle surface.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[CliCommand("set_selection", "Set the Editor selection to the given assets/scene objects.")]
|
||||
public static SelectionResult SetSelection(
|
||||
[CliArg("instance_ids", "Scene/loaded object instance IDs to select.")] ObjectId[] instanceIds = null,
|
||||
[CliArg("paths", "Asset paths to select (e.g. Assets/Foo.prefab).")] string[] paths = null)
|
||||
{
|
||||
var resolved = new List<Object>();
|
||||
var unresolved = new List<string>();
|
||||
|
||||
if (instanceIds != null)
|
||||
{
|
||||
foreach (var id in instanceIds)
|
||||
{
|
||||
var obj = PipelineUtils.IdToObject(id);
|
||||
if (obj != null)
|
||||
resolved.Add(obj);
|
||||
else
|
||||
unresolved.Add($"instanceId:{id}");
|
||||
}
|
||||
}
|
||||
|
||||
if (paths != null)
|
||||
{
|
||||
foreach (var path in paths)
|
||||
{
|
||||
var obj = string.IsNullOrEmpty(path) ? null : AssetDatabase.LoadMainAssetAtPath(path);
|
||||
if (obj != null)
|
||||
resolved.Add(obj);
|
||||
else if (path == null)
|
||||
unresolved.Add("<null>");
|
||||
else if (path.Length == 0)
|
||||
unresolved.Add("<empty>");
|
||||
else
|
||||
unresolved.Add(path);
|
||||
}
|
||||
}
|
||||
|
||||
var hadInputs = (instanceIds != null && instanceIds.Length > 0) ||
|
||||
(paths != null && paths.Length > 0);
|
||||
|
||||
// Inputs were given but none resolved: that's a hard error, not a silent clear.
|
||||
if (resolved.Count == 0 && hadInputs)
|
||||
throw new ArgumentException($"No objects resolved from the given inputs. Unresolved: {string.Join(", ", unresolved)}");
|
||||
|
||||
// No inputs at all is a valid request to clear the selection.
|
||||
var objects = resolved.ToArray();
|
||||
Selection.objects = objects;
|
||||
Selection.activeObject = resolved.FirstOrDefault();
|
||||
|
||||
return DescribeSelection(unresolved.ToArray());
|
||||
}
|
||||
|
||||
[CliCommand("search", "Run a Unity Search query and return structured results.")]
|
||||
public static SearchResult Search(
|
||||
[CliArg("query", "Unity Search query string, e.g. 't:Material', 'p: my asset', 'h: Main Camera'.", Required = true)] string query,
|
||||
[CliArg("limit", "Max results to return (capped 200).")] int limit = 50)
|
||||
{
|
||||
if (string.IsNullOrEmpty(query))
|
||||
throw new ArgumentException("query must be a non-empty Unity Search query string.");
|
||||
|
||||
var clamped = Mathf.Clamp(limit, 0, MaxSearchResults);
|
||||
|
||||
// A non-positive limit can't return any rows; skip the potentially expensive request.
|
||||
if (clamped <= 0)
|
||||
{
|
||||
return new SearchResult
|
||||
{
|
||||
Query = query,
|
||||
Count = 0,
|
||||
Results = Array.Empty<SearchResultItem>()
|
||||
};
|
||||
}
|
||||
|
||||
var results = new List<SearchResultItem>();
|
||||
|
||||
ISearchList list = null;
|
||||
try
|
||||
{
|
||||
// Synchronous request returns the items already resolved on the main thread.
|
||||
list = SearchService.Request(query, SearchFlags.Synchronous);
|
||||
|
||||
foreach (var item in list)
|
||||
{
|
||||
if (results.Count >= clamped)
|
||||
break;
|
||||
if (item == null)
|
||||
continue;
|
||||
|
||||
// One malformed item must not fail the whole call.
|
||||
try
|
||||
{
|
||||
results.Add(MapItem(item));
|
||||
}
|
||||
catch
|
||||
{
|
||||
// skip the unmappable item
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"Unity Search query '{query}' failed: {ex.Message}", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The Synchronous Request overload returns an ISearchList that owns a SearchContext;
|
||||
// dispose it when present so we don't leak the context.
|
||||
(list as IDisposable)?.Dispose();
|
||||
}
|
||||
|
||||
return new SearchResult
|
||||
{
|
||||
Query = query,
|
||||
Count = results.Count,
|
||||
Results = results.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static SearchResultItem MapItem(SearchItem item)
|
||||
{
|
||||
string label;
|
||||
try
|
||||
{
|
||||
label = item.GetLabel(item.context, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
label = null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(label))
|
||||
label = item.label ?? item.id;
|
||||
|
||||
string description = null;
|
||||
try
|
||||
{
|
||||
description = item.GetDescription(item.context, true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// leave description null
|
||||
}
|
||||
|
||||
string path = null;
|
||||
try
|
||||
{
|
||||
var obj = item.ToObject();
|
||||
if (obj != null)
|
||||
{
|
||||
var assetPath = AssetDatabase.GetAssetPath(obj);
|
||||
if (!string.IsNullOrEmpty(assetPath))
|
||||
path = assetPath;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// leave path null
|
||||
}
|
||||
|
||||
return new SearchResultItem
|
||||
{
|
||||
Id = item.id,
|
||||
Label = label,
|
||||
Description = description,
|
||||
Provider = item.provider?.id,
|
||||
Path = path
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the current <see cref="Selection"/> as canonical identities, optionally tagging on
|
||||
/// the <paramref name="unresolved"/> list from a set_selection call.
|
||||
/// </summary>
|
||||
private static SelectionResult DescribeSelection(string[] unresolved)
|
||||
{
|
||||
var objects = Selection.objects ?? Array.Empty<Object>();
|
||||
var described = objects
|
||||
.Select(ObjectResolver.Describe)
|
||||
.Where(r => r != null)
|
||||
.ToArray();
|
||||
|
||||
return new SelectionResult
|
||||
{
|
||||
Count = described.Length,
|
||||
Active = ObjectResolver.Describe(Selection.activeObject),
|
||||
Objects = described,
|
||||
Unresolved = unresolved
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structured snapshot of the Editor selection: the active object plus every selected object,
|
||||
/// each as a canonical <see cref="AuthoringResult"/> identity. <see cref="Unresolved"/> is only
|
||||
/// populated by set_selection (inputs that did not resolve to a loaded object/asset).
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class SelectionResult
|
||||
{
|
||||
/// <summary>Number of selected objects (excludes nulls).</summary>
|
||||
[JsonProperty("count")]
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>The active selection object, or null when the selection is empty.</summary>
|
||||
[JsonProperty("active")]
|
||||
public AuthoringResult Active { get; set; }
|
||||
|
||||
/// <summary>Every selected object as a canonical identity (nulls skipped).</summary>
|
||||
[JsonProperty("objects")]
|
||||
public AuthoringResult[] Objects { get; set; }
|
||||
|
||||
/// <summary>Inputs that did not resolve (set_selection only); null for get_selection.</summary>
|
||||
[JsonProperty("unresolved")]
|
||||
public string[] Unresolved { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structured result of a Unity Search query: the echoed query, the returned count, and the
|
||||
/// mapped <see cref="SearchResultItem"/> rows (capped at the requested limit).
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class SearchResult
|
||||
{
|
||||
/// <summary>The query that was executed (echoed back).</summary>
|
||||
[JsonProperty("query")]
|
||||
public string Query { get; set; }
|
||||
|
||||
/// <summary>Number of results returned (after the limit cap).</summary>
|
||||
[JsonProperty("count")]
|
||||
public int Count { get; set; }
|
||||
|
||||
/// <summary>The mapped search result rows.</summary>
|
||||
[JsonProperty("results")]
|
||||
public SearchResultItem[] Results { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One Unity Search result, flattened to portable fields. <see cref="Path"/> is populated only
|
||||
/// when the item resolves to an asset object.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class SearchResultItem
|
||||
{
|
||||
/// <summary>Provider-specific item id (e.g. an asset path or scene object id).</summary>
|
||||
[JsonProperty("id")]
|
||||
public string Id { get; set; }
|
||||
|
||||
/// <summary>Display label for the item.</summary>
|
||||
[JsonProperty("label")]
|
||||
public string Label { get; set; }
|
||||
|
||||
/// <summary>Longer description, when the provider supplies one (may be null).</summary>
|
||||
[JsonProperty("description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
/// <summary>Id of the search provider that produced this item (e.g. "asset", "scene").</summary>
|
||||
[JsonProperty("provider")]
|
||||
public string Provider { get; set; }
|
||||
|
||||
/// <summary>Project-relative asset path, when the item resolves to an asset (else null).</summary>
|
||||
[JsonProperty("path")]
|
||||
public string Path { get; set; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a16797feca5bc694cac48b892634bb7a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a7cc3922a14b4741a17d985bb6be540
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using Unity.Pipeline.Commands;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Observability
|
||||
{
|
||||
/// <summary>
|
||||
/// Read-only observability commands over the captured Editor console (CLI-198). Logs are captured
|
||||
/// continuously by <see cref="ConsoleLogBuffer"/>; these commands let an agent read them back as
|
||||
/// structured data and clear the buffer (and the Unity console) between runs.
|
||||
/// </summary>
|
||||
public static class ConsoleCommands
|
||||
{
|
||||
[CliCommand("get_console_logs", "Read recently captured Editor console logs (structured).")]
|
||||
public static object GetConsoleLogs(
|
||||
[CliArg("severity", "Filter: all | log | warning | error. 'all' = every entry; 'log' = Log only; 'warning' = Warning only; 'error' = Error/Exception/Assert only.")] string severity = "all",
|
||||
[CliArg("limit", "Max entries to return (most-recent first), capped at 1000.")] int limit = 100)
|
||||
{
|
||||
// Snapshot is oldest-first; filter by tier, then take the most-recent `limit` entries
|
||||
// and present them newest-first.
|
||||
var snapshot = ConsoleLogBuffer.Snapshot();
|
||||
var filtered = new List<ConsoleLogEntryDto>(snapshot.Count);
|
||||
foreach (var entry in snapshot)
|
||||
{
|
||||
if (MatchesSeverity(entry.Type, severity))
|
||||
filtered.Add(entry);
|
||||
}
|
||||
|
||||
var clampedLimit = Math.Min(Math.Max(limit, 1), ConsoleLogBuffer.MaxEntries);
|
||||
|
||||
var logs = new List<ConsoleLogEntryDto>(Math.Min(clampedLimit, filtered.Count));
|
||||
for (var i = filtered.Count - 1; i >= 0 && logs.Count < clampedLimit; i--)
|
||||
logs.Add(filtered[i]);
|
||||
|
||||
return new
|
||||
{
|
||||
total = filtered.Count,
|
||||
returned = logs.Count,
|
||||
logs
|
||||
};
|
||||
}
|
||||
|
||||
[CliCommand("clear_console", "Clear the captured log buffer and the Unity Editor console.")]
|
||||
public static object ClearConsole()
|
||||
{
|
||||
ConsoleLogBuffer.Clear();
|
||||
|
||||
// Best-effort: also clear Unity's own console window. UnityEditor.LogEntries is internal,
|
||||
// so reach it via reflection and swallow any failure (API is not part of the public contract).
|
||||
// LogEntries.Clear is a static, non-public method, so include NonPublic in the binding flags —
|
||||
// GetMethod("Clear") with no flags only finds public methods and would silently return null.
|
||||
try
|
||||
{
|
||||
Type.GetType("UnityEditor.LogEntries,UnityEditor")
|
||||
?.GetMethod("Clear", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)
|
||||
?.Invoke(null, null);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore — clearing the Editor console is auxiliary; the buffer is already cleared.
|
||||
}
|
||||
|
||||
return new { cleared = true };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map a Unity <see cref="UnityEngine.LogType"/> name onto the requested severity tier.
|
||||
/// The tiers are distinct: "all" matches every entry; "log" matches Log only;
|
||||
/// "warning" matches Warning only; "error" matches Error/Exception/Assert only.
|
||||
/// Any unknown value falls back to "all" (matches everything).
|
||||
/// </summary>
|
||||
private static bool MatchesSeverity(string type, string severity)
|
||||
{
|
||||
switch (severity?.ToLowerInvariant())
|
||||
{
|
||||
case "log":
|
||||
return type == "Log";
|
||||
case "warning":
|
||||
return type == "Warning";
|
||||
case "error":
|
||||
return type == "Error" || type == "Exception" || type == "Assert";
|
||||
case "all":
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b7dd929510db8d248b64b05361bfabb6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Observability
|
||||
{
|
||||
/// <summary>
|
||||
/// A single captured Editor console entry. Returned (most-recent-first) by the
|
||||
/// <c>get_console_logs</c> command so an agent can read structured log output without scraping
|
||||
/// the Editor UI. Mirrors the fields Unity surfaces on <see cref="Application.logMessageReceivedThreaded"/>.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class ConsoleLogEntryDto
|
||||
{
|
||||
/// <summary>Unity <see cref="LogType"/> name, e.g. "Log", "Warning", "Error", "Exception", "Assert".</summary>
|
||||
[JsonProperty("type")]
|
||||
public string Type { get; set; }
|
||||
|
||||
/// <summary>The logged message text.</summary>
|
||||
[JsonProperty("message")]
|
||||
public string Message { get; set; }
|
||||
|
||||
/// <summary>Captured stack trace, if Unity provided one for the entry.</summary>
|
||||
[JsonProperty("stackTrace")]
|
||||
public string StackTrace { get; set; }
|
||||
|
||||
/// <summary>Capture time in UTC, ISO-8601 round-trip ("o") format.</summary>
|
||||
[JsonProperty("timestampUtc")]
|
||||
public string TimestampUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures Editor console output into a bounded, thread-safe ring buffer so it can be read back
|
||||
/// structurally over the pipeline (CLI-198). Subscribes to
|
||||
/// <see cref="Application.logMessageReceivedThreaded"/> on load — the threaded variant is used
|
||||
/// because Unity may raise log callbacks off the main thread, and the handler is fully lock-guarded.
|
||||
///
|
||||
/// The buffer holds at most <see cref="MaxEntries"/> entries; the oldest is dropped when full.
|
||||
/// </summary>
|
||||
public static class ConsoleLogBuffer
|
||||
{
|
||||
/// <summary>Maximum number of entries retained; oldest entries are dropped past this.</summary>
|
||||
public const int MaxEntries = 1000;
|
||||
|
||||
private static readonly object m_Lock = new object();
|
||||
private static readonly Queue<ConsoleLogEntryDto> m_Entries = new Queue<ConsoleLogEntryDto>(MaxEntries);
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to Unity's threaded log callback as soon as the Editor domain loads, so capture
|
||||
/// is live before any command runs. Idempotent across domain reloads (handler is removed
|
||||
/// first to avoid double-subscription if Init somehow runs twice).
|
||||
/// </summary>
|
||||
[InitializeOnLoadMethod]
|
||||
private static void Init()
|
||||
{
|
||||
Application.logMessageReceivedThreaded -= OnLogMessageThreaded;
|
||||
Application.logMessageReceivedThreaded += OnLogMessageThreaded;
|
||||
}
|
||||
|
||||
private static void OnLogMessageThreaded(string condition, string stackTrace, LogType type)
|
||||
{
|
||||
var entry = new ConsoleLogEntryDto
|
||||
{
|
||||
Type = type.ToString(),
|
||||
Message = condition,
|
||||
StackTrace = stackTrace,
|
||||
TimestampUtc = DateTime.UtcNow.ToString("o")
|
||||
};
|
||||
|
||||
lock (m_Lock)
|
||||
{
|
||||
if (m_Entries.Count >= MaxEntries)
|
||||
m_Entries.Dequeue();
|
||||
m_Entries.Enqueue(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Take a point-in-time copy of the buffer in chronological (oldest-first) order. The copy is
|
||||
/// detached from the live buffer, so callers can filter/reverse it without holding the lock.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ConsoleLogEntryDto> Snapshot()
|
||||
{
|
||||
lock (m_Lock)
|
||||
{
|
||||
return new List<ConsoleLogEntryDto>(m_Entries);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Number of entries currently retained in the buffer.</summary>
|
||||
public static int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (m_Lock)
|
||||
{
|
||||
return m_Entries.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drop all captured entries.</summary>
|
||||
public static void Clear()
|
||||
{
|
||||
lock (m_Lock)
|
||||
{
|
||||
m_Entries.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d212c06f2c819e04c892f7e41520f975
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
using System;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Profiling;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Observability
|
||||
{
|
||||
/// <summary>
|
||||
/// Render statistics for the most recently rendered Editor frame, read from
|
||||
/// <see cref="UnityStats"/>. These reflect the last frame the Editor drew (Game/Scene view), not
|
||||
/// an averaged measurement — treat them as a snapshot.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class RenderStats
|
||||
{
|
||||
/// <summary>Draw calls issued for the last rendered frame.</summary>
|
||||
[JsonProperty("drawCalls")]
|
||||
public int DrawCalls { get; set; }
|
||||
|
||||
/// <summary>Total batches (dynamic + static + instanced) for the last rendered frame.</summary>
|
||||
[JsonProperty("batches")]
|
||||
public int Batches { get; set; }
|
||||
|
||||
/// <summary>SetPass calls (shader pass switches) for the last rendered frame.</summary>
|
||||
[JsonProperty("setPassCalls")]
|
||||
public int SetPassCalls { get; set; }
|
||||
|
||||
/// <summary>Triangles submitted for the last rendered frame.</summary>
|
||||
[JsonProperty("triangles")]
|
||||
public int Triangles { get; set; }
|
||||
|
||||
/// <summary>Vertices submitted for the last rendered frame.</summary>
|
||||
[JsonProperty("vertices")]
|
||||
public int Vertices { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Process memory usage read from <see cref="Profiler"/>. Values are in bytes and are always
|
||||
/// available at runtime regardless of whether the Profiler window is open.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class MemoryStats
|
||||
{
|
||||
/// <summary>Total memory currently allocated by Unity (bytes).</summary>
|
||||
[JsonProperty("totalAllocatedBytes")]
|
||||
public long TotalAllocatedBytes { get; set; }
|
||||
|
||||
/// <summary>Total memory reserved by Unity (bytes).</summary>
|
||||
[JsonProperty("totalReservedBytes")]
|
||||
public long TotalReservedBytes { get; set; }
|
||||
|
||||
/// <summary>Mono managed heap memory in use (bytes).</summary>
|
||||
[JsonProperty("monoUsedBytes")]
|
||||
public long MonoUsedBytes { get; set; }
|
||||
|
||||
/// <summary>Mono managed heap size reserved (bytes).</summary>
|
||||
[JsonProperty("monoHeapBytes")]
|
||||
public long MonoHeapBytes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Latest CPU/GPU frame timing from <see cref="FrameTimingManager"/>. The Frame Timing Manager
|
||||
/// must have a timing captured for <see cref="Available"/> to be true; when unavailable the
|
||||
/// timing fields are left at zero.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class FrameTimingStats
|
||||
{
|
||||
/// <summary>True when a frame timing was captured and the milliseconds fields are meaningful.</summary>
|
||||
[JsonProperty("available")]
|
||||
public bool Available { get; set; }
|
||||
|
||||
/// <summary>Total CPU frame time in milliseconds.</summary>
|
||||
[JsonProperty("cpuFrameTimeMs")]
|
||||
public double CpuFrameTimeMs { get; set; }
|
||||
|
||||
/// <summary>Total GPU frame time in milliseconds.</summary>
|
||||
[JsonProperty("gpuFrameTimeMs")]
|
||||
public double GpuFrameTimeMs { get; set; }
|
||||
|
||||
/// <summary>CPU main-thread frame time in milliseconds.</summary>
|
||||
[JsonProperty("cpuMainThreadFrameTimeMs")]
|
||||
public double CpuMainThreadFrameTimeMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composite performance snapshot returned by <c>get_performance_stats</c>: render counters,
|
||||
/// process memory, and the latest frame timing.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class PerformanceStats
|
||||
{
|
||||
/// <summary>Render counters for the last rendered Editor frame.</summary>
|
||||
[JsonProperty("render")]
|
||||
public RenderStats Render { get; set; }
|
||||
|
||||
/// <summary>Process memory usage at capture time.</summary>
|
||||
[JsonProperty("memory")]
|
||||
public MemoryStats Memory { get; set; }
|
||||
|
||||
/// <summary>Latest CPU/GPU frame timing, if one was captured.</summary>
|
||||
[JsonProperty("frameTiming")]
|
||||
public FrameTimingStats FrameTiming { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read-only telemetry command (CLI-198) that snapshots render, memory, and frame-timing stats so
|
||||
/// an agent can reason about Editor performance without opening the Profiler or Stats overlay.
|
||||
/// </summary>
|
||||
public static class PerformanceCommands
|
||||
{
|
||||
[CliCommand("get_performance_stats", "Read render, memory, and frame-timing stats (structured, read-only).")]
|
||||
public static PerformanceStats GetPerformanceStats()
|
||||
{
|
||||
return new PerformanceStats
|
||||
{
|
||||
Render = new RenderStats
|
||||
{
|
||||
// UnityStats reflects the last frame rendered by the Editor (Game/Scene view).
|
||||
DrawCalls = UnityStats.drawCalls,
|
||||
Batches = UnityStats.dynamicBatches + UnityStats.staticBatches + UnityStats.instancedBatches,
|
||||
SetPassCalls = UnityStats.setPassCalls,
|
||||
Triangles = UnityStats.triangles,
|
||||
Vertices = UnityStats.vertices
|
||||
},
|
||||
Memory = new MemoryStats
|
||||
{
|
||||
TotalAllocatedBytes = Profiler.GetTotalAllocatedMemoryLong(),
|
||||
TotalReservedBytes = Profiler.GetTotalReservedMemoryLong(),
|
||||
MonoUsedBytes = Profiler.GetMonoUsedSizeLong(),
|
||||
MonoHeapBytes = Profiler.GetMonoHeapSizeLong()
|
||||
},
|
||||
FrameTiming = CaptureFrameTiming()
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture the latest CPU/GPU frame timing. The Frame Timing Manager only yields data once a
|
||||
/// timing has been captured (and platform support exists), so any failure or empty result is
|
||||
/// reported as <see cref="FrameTimingStats.Available"/> = false rather than throwing.
|
||||
/// </summary>
|
||||
private static FrameTimingStats CaptureFrameTiming()
|
||||
{
|
||||
try
|
||||
{
|
||||
FrameTimingManager.CaptureFrameTimings();
|
||||
|
||||
var timings = new FrameTiming[1];
|
||||
uint received = FrameTimingManager.GetLatestTimings(1, timings);
|
||||
if (received > 0)
|
||||
{
|
||||
return new FrameTimingStats
|
||||
{
|
||||
Available = true,
|
||||
CpuFrameTimeMs = timings[0].cpuFrameTime,
|
||||
GpuFrameTimeMs = timings[0].gpuFrameTime,
|
||||
CpuMainThreadFrameTimeMs = timings[0].cpuMainThreadFrameTime
|
||||
};
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Frame timing is platform-dependent and may be unsupported; report as unavailable.
|
||||
}
|
||||
|
||||
return new FrameTimingStats { Available = false };
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ae9262a38e20733409c3a084cb810bdc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fdbdf731f3ad41fca20634ebcf5f4d7e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
using System;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.PackageManager
|
||||
{
|
||||
/// <summary>Where an added package comes from, derived from its identifier string (CLI-203).</summary>
|
||||
public enum PackageSourceKind
|
||||
{
|
||||
/// <summary>Could not be classified (e.g. empty input).</summary>
|
||||
Unknown,
|
||||
|
||||
/// <summary>A registry package name, optionally pinned: <c>com.unity.foo</c> or <c>com.unity.foo@1.2.3</c>.</summary>
|
||||
Registry,
|
||||
|
||||
/// <summary>A git URL: <c>https://….git</c>, <c>git+…</c>, <c>ssh://…</c>, <c>git@host:…</c>, optionally <c>#revision</c>.</summary>
|
||||
Git,
|
||||
|
||||
/// <summary>A local package reference: <c>file:../RelativePath</c> or <c>file:/abs/path</c>.</summary>
|
||||
Local
|
||||
}
|
||||
|
||||
/// <summary>A parsed UPM <c>package_add</c> identifier — the normalized string handed to
|
||||
/// <c>Client.Add</c> plus the pieces extracted for plan/audit text.</summary>
|
||||
public sealed class ParsedPackageId
|
||||
{
|
||||
/// <summary>How the package will be resolved by UPM.</summary>
|
||||
public PackageSourceKind Kind { get; set; }
|
||||
|
||||
/// <summary>The identifier passed verbatim to <c>UnityEditor.PackageManager.Client.Add</c>.</summary>
|
||||
public string Identifier { get; set; }
|
||||
|
||||
/// <summary>Registry package name (Registry only); null otherwise.</summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>Registry version or git revision when given; null otherwise.</summary>
|
||||
public string Version { get; set; }
|
||||
|
||||
/// <summary>Human-readable summary for plan/audit text.</summary>
|
||||
public string Description { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure, editor-independent classification of a <c>package_add</c> identifier into a registry name,
|
||||
/// git URL, or local path (CLI-203). Kept free of any Unity API so the add-path's input handling is
|
||||
/// unit-testable without a live editor; the command layer feeds the result to
|
||||
/// <c>UnityEditor.PackageManager.Client.Add</c>.
|
||||
/// </summary>
|
||||
public static class PackageIdentifier
|
||||
{
|
||||
/// <summary>Classify an identifier without parsing out its parts.</summary>
|
||||
public static PackageSourceKind Classify(string identifier)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(identifier))
|
||||
return PackageSourceKind.Unknown;
|
||||
|
||||
var id = identifier.Trim();
|
||||
|
||||
if (StartsWith(id, "file:"))
|
||||
return PackageSourceKind.Local;
|
||||
|
||||
// Explicit git forms and any URL scheme (UPM treats http(s) identifiers as git URLs).
|
||||
if (StartsWith(id, "git+") || StartsWith(id, "ssh://") || id.StartsWith("git@", StringComparison.Ordinal))
|
||||
return PackageSourceKind.Git;
|
||||
if (StartsWith(id, "http://") || StartsWith(id, "https://") || id.Contains("://"))
|
||||
return PackageSourceKind.Git;
|
||||
|
||||
// A bare "owner/repo.git" (no scheme) is still a git reference. Strip an optional
|
||||
// "#revision" before testing the suffix.
|
||||
var core = id;
|
||||
var hash = core.IndexOf('#');
|
||||
if (hash >= 0)
|
||||
core = core.Substring(0, hash);
|
||||
if (core.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
|
||||
return PackageSourceKind.Git;
|
||||
|
||||
return PackageSourceKind.Registry;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Classify and split <paramref name="identifier"/>. Returns false with <paramref name="error"/>
|
||||
/// set for empty/unclassifiable input.
|
||||
/// </summary>
|
||||
public static bool TryParse(string identifier, out ParsedPackageId parsed, out string error)
|
||||
{
|
||||
parsed = null;
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(identifier))
|
||||
{
|
||||
error = "Package identifier is empty. Provide a name (com.unity.foo[@version]), a git URL, or a file: path.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var id = identifier.Trim();
|
||||
switch (Classify(id))
|
||||
{
|
||||
case PackageSourceKind.Local:
|
||||
parsed = new ParsedPackageId
|
||||
{
|
||||
Kind = PackageSourceKind.Local,
|
||||
Identifier = id,
|
||||
Description = $"local package '{id}'"
|
||||
};
|
||||
return true;
|
||||
|
||||
case PackageSourceKind.Git:
|
||||
{
|
||||
string revision = null;
|
||||
var hash = id.IndexOf('#');
|
||||
if (hash >= 0 && hash < id.Length - 1)
|
||||
revision = id.Substring(hash + 1);
|
||||
parsed = new ParsedPackageId
|
||||
{
|
||||
Kind = PackageSourceKind.Git,
|
||||
Identifier = id,
|
||||
Version = revision,
|
||||
Description = revision != null ? $"git package '{id}' @ {revision}" : $"git package '{id}'"
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
case PackageSourceKind.Registry:
|
||||
{
|
||||
var name = id;
|
||||
string version = null;
|
||||
|
||||
// Split on the version separator '@'. Use the last '@' so npm-scoped names
|
||||
// ("@scope/pkg@1.0.0") still split on the version, not the leading scope marker.
|
||||
var at = id.LastIndexOf('@');
|
||||
if (at > 0)
|
||||
{
|
||||
name = id.Substring(0, at);
|
||||
version = id.Substring(at + 1);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
error = $"Package name is empty in '{identifier}'.";
|
||||
return false;
|
||||
}
|
||||
|
||||
parsed = new ParsedPackageId
|
||||
{
|
||||
Kind = PackageSourceKind.Registry,
|
||||
Identifier = id,
|
||||
Name = name,
|
||||
Version = version,
|
||||
Description = version != null ? $"'{name}' @ {version}" : $"'{name}' (latest compatible)"
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
default:
|
||||
error = $"Could not classify package identifier '{identifier}'.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool StartsWith(string value, string prefix) =>
|
||||
value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b95405e8a4f04c93acbdd564a74f8cc2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+644
@@ -0,0 +1,644 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEditor.PackageManager;
|
||||
using UnityEditor.PackageManager.Requests;
|
||||
using UnityEngine;
|
||||
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.PackageManager
|
||||
{
|
||||
/// <summary>
|
||||
/// UPM package management over the Client API (CLI-203): list / search / add / remove / resolve.
|
||||
///
|
||||
/// Threading: UPM <see cref="Client"/> operations are asynchronous (the <see cref="Request"/> only
|
||||
/// progresses while the editor ticks) and their members are main-thread only. Commands that wait on a
|
||||
/// request therefore run off the main thread (<c>MainThreadRequired = false</c>) and marshal every
|
||||
/// UPM touch back onto the main thread via the server dispatcher (<see cref="RunOnMain"/>), so they
|
||||
/// can block without freezing the editor.
|
||||
///
|
||||
/// Command surface:
|
||||
/// - <c>package_list</c> (installed scope) reads the resolved set inline; <c>available</c>/<c>all</c>
|
||||
/// and <c>package_search</c> query the registry and wait for it — all return <b>synchronously</b>.
|
||||
/// - <c>package_add</c>/<c>package_remove</c> mutate the manifest and trigger a <b>domain reload</b>
|
||||
/// that tears down the in-flight request and the HTTP connection. They are <b>dual-mode</b>:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>async (default)</b> — kick the op off, return <c>in_progress</c> immediately, and let
|
||||
/// an <see cref="EditorApplication.update"/> poller finalize a Temp status file. Poll
|
||||
/// <c>package_status</c> until <c>completed</c>/<c>failed</c>, then <c>recompile_status</c>. This
|
||||
/// keeps the (single-request) server responsive and survives the reload.</description></item>
|
||||
/// <item><description><b>synchronous</b> (<c>wait=true</c>) — block until the request completes and return the
|
||||
/// full result. The result is captured in one main-thread hop the moment the request completes
|
||||
/// (before the compile-triggered reload). Reliable for add; for remove the reload can fire fast
|
||||
/// enough to drop the reply — the status file still settles, so <c>package_status</c> confirms it.</description></item>
|
||||
/// </list>
|
||||
/// Both write the status file, so a lost reply (or a reload mid-wait) is recoverable via
|
||||
/// <c>package_status</c>; <see cref="RecoverInterruptedOperation"/> settles an <c>in_progress</c>
|
||||
/// file on the next load.
|
||||
/// - <c>package_resolve</c> records its status too, so <c>package_status</c> validates it.
|
||||
///
|
||||
/// Mutating commands (add / remove) follow the shared <c>confirm</c>/<c>dry_run</c> convention.
|
||||
/// UPM operations are not part of Unity's Undo.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class PackageManagerCommand
|
||||
{
|
||||
const string StatusFile = "Temp/pipeline_package_status.json";
|
||||
|
||||
// Bound on a synchronous wait, kept under the CLI's default request timeout so a stuck operation
|
||||
// surfaces as a clean error rather than a dropped connection.
|
||||
const int WaitTimeoutSeconds = 25;
|
||||
const int PollIntervalMs = 50;
|
||||
|
||||
static readonly object s_Lock = new object();
|
||||
|
||||
// The in-flight mutating request (add/remove) and how to project its result. s_InProgress is a
|
||||
// plain flag (read off-thread by IsBusy) so the busy check never touches main-thread-only Request
|
||||
// members. Both are reset by a domain reload; RecoverInterruptedOperation settles the status file.
|
||||
static volatile bool s_InProgress;
|
||||
static Request s_Request;
|
||||
static string s_Operation;
|
||||
static string s_Argument;
|
||||
static Func<Request, PackageSummary> s_ReadPackage;
|
||||
|
||||
static PackageManagerCommand()
|
||||
{
|
||||
RecoverInterruptedOperation();
|
||||
}
|
||||
|
||||
// ---- List / search (synchronous) -------------------------------------------------------
|
||||
|
||||
[CliCommand("package_list",
|
||||
"List packages by scope: installed (default) | available (registry) | all (both). Returns the full " +
|
||||
"result synchronously — available/all block until the registry query completes.",
|
||||
MainThreadRequired = false)]
|
||||
public static object PackageList(
|
||||
[CliArg("scope", "Which packages to list: installed (default) | available | all.")] string scope = "installed",
|
||||
[CliArg("include_indirect", "Include indirect (transitive) installed dependencies (applies to scope=installed/all).")]
|
||||
bool includeIndirect = true,
|
||||
[CliArg("offline", "For available/all: query the local cache instead of the registry.")] bool offline = false)
|
||||
{
|
||||
switch ((scope ?? "installed").Trim().ToLowerInvariant())
|
||||
{
|
||||
case "installed":
|
||||
return RunOnMain(() => ListInstalled(includeIndirect));
|
||||
case "available":
|
||||
return ListFromRegistry("available", includeIndirect, offline);
|
||||
case "all":
|
||||
return ListFromRegistry("all", includeIndirect, offline);
|
||||
default:
|
||||
return new PackageListResponse
|
||||
{
|
||||
Success = false,
|
||||
Scope = scope,
|
||||
Message = $"Unknown scope '{scope}'. Use installed | available | all."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Installed scope: <see cref="PackageInfo.GetAllRegisteredPackages"/> reflects the resolved
|
||||
/// set without a registry round-trip. Runs on the main thread (via <see cref="RunOnMain"/>).
|
||||
/// </summary>
|
||||
static PackageListResponse ListInstalled(bool includeIndirect)
|
||||
{
|
||||
var summaries = new List<PackageSummary>();
|
||||
foreach (var p in PackageInfo.GetAllRegisteredPackages())
|
||||
{
|
||||
if (includeIndirect || p.isDirectDependency)
|
||||
summaries.Add(Map(p, isInstalled: true));
|
||||
}
|
||||
|
||||
return new PackageListResponse
|
||||
{
|
||||
Success = true,
|
||||
Scope = "installed",
|
||||
Count = summaries.Count,
|
||||
Packages = summaries,
|
||||
Manifest = SafeReadManifest(),
|
||||
Message = $"{summaries.Count} installed package(s)."
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// available / all scope: these need the registry, so a <c>Client.SearchAll</c> request is
|
||||
/// started on the main thread and awaited here (on the command's background thread). For
|
||||
/// <c>all</c>, the installed set is merged in (and marked) so callers see one unified list.
|
||||
/// </summary>
|
||||
static object ListFromRegistry(string scope, bool includeIndirect, bool offline)
|
||||
{
|
||||
var request = RunOnMain(() => Client.SearchAll(offline));
|
||||
|
||||
if (!WaitForCompletion(request, out var waitError))
|
||||
return new PackageListResponse { Success = false, Scope = scope, Message = waitError };
|
||||
|
||||
return RunOnMain<object>(() =>
|
||||
{
|
||||
if (request.Status != StatusCode.Success)
|
||||
return new PackageListResponse
|
||||
{
|
||||
Success = false,
|
||||
Scope = scope,
|
||||
Message = $"Registry query failed: {request.Error?.message ?? "unknown error"}"
|
||||
};
|
||||
|
||||
var packages = BuildScopedList(scope, request.Result, includeIndirect);
|
||||
return new PackageListResponse
|
||||
{
|
||||
Success = true,
|
||||
Scope = scope,
|
||||
Count = packages.Count,
|
||||
Packages = packages,
|
||||
Manifest = SafeReadManifest(),
|
||||
Message = $"{packages.Count} package(s)."
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combine the registry search result with the installed set per <paramref name="scope"/>:
|
||||
/// <c>available</c> returns every registry package (flagged whether it is installed);
|
||||
/// <c>all</c> returns installed packages (resolved info) plus the registry packages that are
|
||||
/// not installed.
|
||||
/// </summary>
|
||||
static List<PackageSummary> BuildScopedList(string scope, PackageInfo[] available, bool includeIndirect)
|
||||
{
|
||||
var installed = new Dictionary<string, PackageInfo>();
|
||||
foreach (var p in PackageInfo.GetAllRegisteredPackages())
|
||||
installed[p.name] = p;
|
||||
|
||||
var result = new List<PackageSummary>();
|
||||
|
||||
if (scope == "available")
|
||||
{
|
||||
foreach (var p in available ?? Array.Empty<PackageInfo>())
|
||||
result.Add(Map(p, isInstalled: installed.ContainsKey(p.name)));
|
||||
return result;
|
||||
}
|
||||
|
||||
// scope == "all": installed entries first (with their resolved info), then registry-only ones.
|
||||
var seen = new HashSet<string>();
|
||||
foreach (var entry in installed.Values)
|
||||
{
|
||||
if (!includeIndirect && !entry.isDirectDependency)
|
||||
continue;
|
||||
result.Add(Map(entry, isInstalled: true));
|
||||
seen.Add(entry.name);
|
||||
}
|
||||
foreach (var p in available ?? Array.Empty<PackageInfo>())
|
||||
{
|
||||
if (!seen.Contains(p.name))
|
||||
result.Add(Map(p, isInstalled: false));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("package_search",
|
||||
"Search packages available in the registry. Provide a name (e.g. com.unity.foo) or omit to list all. " +
|
||||
"Returns the full result synchronously (blocks until the registry query completes).",
|
||||
MainThreadRequired = false)]
|
||||
public static object PackageSearch(
|
||||
[CliArg("query", "Package name to search for. Omit/empty to list all available packages.")] string query = "",
|
||||
[CliArg("offline", "Search the local cache only.")] bool offline = false)
|
||||
{
|
||||
var trimmed = (query ?? string.Empty).Trim();
|
||||
|
||||
var request = RunOnMain(() => string.IsNullOrEmpty(trimmed)
|
||||
? Client.SearchAll(offline)
|
||||
: Client.Search(trimmed, offline));
|
||||
|
||||
if (!WaitForCompletion(request, out var waitError))
|
||||
return new PackageSearchResponse { Success = false, Query = trimmed, Message = waitError };
|
||||
|
||||
return RunOnMain<object>(() =>
|
||||
{
|
||||
if (request.Status != StatusCode.Success)
|
||||
return new PackageSearchResponse
|
||||
{
|
||||
Success = false,
|
||||
Query = trimmed,
|
||||
Message = $"Search failed: {request.Error?.message ?? "unknown error"}"
|
||||
};
|
||||
|
||||
var packages = MapMarkingInstalled(request.Result);
|
||||
return new PackageSearchResponse
|
||||
{
|
||||
Success = true,
|
||||
Query = trimmed,
|
||||
Count = packages.Count,
|
||||
Packages = packages,
|
||||
Message = $"{packages.Count} package(s)."
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Mutating operations (dual-mode, CAT-2509 gated) -----------------------------------
|
||||
|
||||
[CliCommand("package_add",
|
||||
"Add a UPM package by name@version, git URL, or 'file:' local path. Async by default (returns " +
|
||||
"in_progress; poll package_status); pass wait=true to block until added. A recompile/domain reload " +
|
||||
"follows — poll recompile_status. Requires confirm=true; use dry_run to preview.",
|
||||
MainThreadRequired = false)]
|
||||
public static object PackageAdd(
|
||||
[CliArg("identifier", "Package to add: 'com.unity.foo@1.2.3', a git URL, or 'file:../Path'.", Required = true)] string identifier = "",
|
||||
[CliArg("confirm", "Apply the change. Without it the call is refused.")] bool confirm = false,
|
||||
[CliArg("dry_run", "Preview the change without applying it.")] bool dryRun = false,
|
||||
[CliArg("wait", "Block until the operation completes and return the result (synchronous). Default: return immediately and poll package_status.")] bool wait = false)
|
||||
{
|
||||
if (!PackageIdentifier.TryParse(identifier, out var parsed, out var parseError))
|
||||
return PackageMutationResponse.Failed("add", identifier, parseError);
|
||||
|
||||
if (IsBusy())
|
||||
return PackageMutationResponse.Busy("add");
|
||||
|
||||
return ExecuteMutation(
|
||||
operation: "add",
|
||||
commandName: "package_add",
|
||||
argument: parsed.Identifier,
|
||||
planText: $"Add package {parsed.Description} [{parsed.Kind}]",
|
||||
confirm: confirm,
|
||||
dryRun: dryRun,
|
||||
wait: wait,
|
||||
start: () => Client.Add(parsed.Identifier),
|
||||
readPackage: req =>
|
||||
{
|
||||
var added = (req as AddRequest)?.Result;
|
||||
return added != null ? Map(added, isInstalled: true) : null;
|
||||
});
|
||||
}
|
||||
|
||||
[CliCommand("package_remove",
|
||||
"Remove a UPM package by name. Async by default (returns in_progress; poll package_status); pass " +
|
||||
"wait=true to block until removed. A recompile/domain reload follows — poll recompile_status. " +
|
||||
"Requires confirm=true; use dry_run to preview.",
|
||||
MainThreadRequired = false)]
|
||||
public static object PackageRemove(
|
||||
[CliArg("name", "Package name to remove (e.g. com.unity.foo).", Required = true)] string name = "",
|
||||
[CliArg("confirm", "Apply the change. Without it the call is refused.")] bool confirm = false,
|
||||
[CliArg("dry_run", "Preview the change without applying it.")] bool dryRun = false,
|
||||
[CliArg("wait", "Block until the operation completes and return the result (synchronous). Default: return immediately and poll package_status.")] bool wait = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return PackageMutationResponse.Failed("remove", name, "A package name is required.");
|
||||
|
||||
var packageName = name.Trim();
|
||||
|
||||
if (IsBusy())
|
||||
return PackageMutationResponse.Busy("remove");
|
||||
|
||||
return ExecuteMutation(
|
||||
operation: "remove",
|
||||
commandName: "package_remove",
|
||||
argument: packageName,
|
||||
planText: $"Remove package '{packageName}'",
|
||||
confirm: confirm,
|
||||
dryRun: dryRun,
|
||||
wait: wait,
|
||||
start: () => Client.Remove(packageName),
|
||||
readPackage: null);
|
||||
}
|
||||
|
||||
[CliCommand("package_resolve",
|
||||
"Resolve/refresh packages from the manifest (re-fetch and re-link). May trigger a recompile/domain " +
|
||||
"reload — poll recompile_status. Its outcome is recorded for package_status.",
|
||||
MainThreadRequired = true)]
|
||||
public static object PackageResolve()
|
||||
{
|
||||
WriteStatus(new PackageStatus
|
||||
{
|
||||
Status = "in_progress",
|
||||
Operation = "resolve",
|
||||
StartedAt = NowIso(),
|
||||
Message = "Resolving packages..."
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
// Client.Resolve() is fire-and-forget (returns void) and schedules a resolve on a later
|
||||
// tick, so the response flushes before any reload — no request to wait on.
|
||||
Client.Resolve();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var failed = new PackageStatus
|
||||
{
|
||||
Status = "failed",
|
||||
Operation = "resolve",
|
||||
Success = false,
|
||||
Error = ex.Message,
|
||||
Manifest = SafeReadManifest(),
|
||||
CompletedAt = NowIso(),
|
||||
Message = $"Resolve failed: {ex.Message}"
|
||||
};
|
||||
WriteStatus(failed);
|
||||
return PackageMutationResponse.Failed("resolve", null, failed.Error);
|
||||
}
|
||||
|
||||
var done = new PackageStatus
|
||||
{
|
||||
Status = "completed",
|
||||
Operation = "resolve",
|
||||
Success = true,
|
||||
RequiresRecompile = true,
|
||||
Manifest = SafeReadManifest(),
|
||||
CompletedAt = NowIso(),
|
||||
Message = "Resolve requested. If assemblies changed, a domain reload follows — poll recompile_status."
|
||||
};
|
||||
WriteStatus(done);
|
||||
return ToMutationResponse(done, plan: null);
|
||||
}
|
||||
|
||||
// ---- Status ----------------------------------------------------------------------------
|
||||
|
||||
[CliCommand("package_status",
|
||||
"Status of the last async package operation (add/remove/resolve): idle | in_progress | completed | " +
|
||||
"failed, with the added package, manifest, and any error.",
|
||||
MainThreadRequired = false)]
|
||||
public static string GetPackageStatus()
|
||||
{
|
||||
if (File.Exists(StatusFile))
|
||||
return File.ReadAllText(StatusFile);
|
||||
return "{\"status\":\"idle\"}";
|
||||
}
|
||||
|
||||
// ---- Mutation execution ----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Gate (confirm / dry-run) and kick off the mutating op, then
|
||||
/// either return <c>in_progress</c> (async, default) or block until completion (<paramref name="wait"/>).
|
||||
/// Either way the operation is tracked and a status file is written, so the result is recoverable
|
||||
/// via <c>package_status</c> even if the domain reload severs a synchronous reply.
|
||||
/// </summary>
|
||||
static object ExecuteMutation(
|
||||
string operation, string commandName, string argument, string planText,
|
||||
bool confirm, bool dryRun, bool wait,
|
||||
Func<Request> start, Func<Request, PackageSummary> readPackage)
|
||||
{
|
||||
if (dryRun)
|
||||
return PackageMutationResponse.DryRunPreview(operation, argument, planText, $"Dry run — {planText}");
|
||||
if (!confirm)
|
||||
return PackageMutationResponse.Rejected(operation, argument,
|
||||
"Refused: this changes project packages. Re-run with confirm=true to apply, or dry_run=true to preview.");
|
||||
|
||||
// UPM operations are not part of Unity's Undo system, so there is no undo scope here.
|
||||
var request = RunOnMain(() =>
|
||||
{
|
||||
var r = start();
|
||||
// async mode finalizes via the update-loop poller; sync mode finalizes itself.
|
||||
BeginTracking(operation, argument, r, readPackage, subscribePoll: !wait);
|
||||
return r;
|
||||
});
|
||||
|
||||
if (request == null)
|
||||
return PackageMutationResponse.Failed(operation, argument, "Operation was confirmed but failed to start.");
|
||||
|
||||
if (!wait)
|
||||
return PackageMutationResponse.InProgress(operation, argument, planText);
|
||||
|
||||
// Synchronous: poll until complete, capturing status + result atomically on the main thread
|
||||
// (a reload can only fire between ticks, so the snapshot can't be interleaved).
|
||||
var deadline = DateTime.UtcNow.AddSeconds(WaitTimeoutSeconds);
|
||||
while (true)
|
||||
{
|
||||
var status = RunOnMain(TryFinalize);
|
||||
if (status != null)
|
||||
return ToMutationResponse(status, planText);
|
||||
|
||||
if (DateTime.UtcNow > deadline)
|
||||
{
|
||||
// Hand off to the update-loop poller so package_status still settles after we bail.
|
||||
RunOnMain<object>(() => { SubscribePoll(); return null; });
|
||||
return PackageMutationResponse.Failed(operation, argument,
|
||||
$"Timed out after {WaitTimeoutSeconds}s; the operation may still be in progress — poll package_status.");
|
||||
}
|
||||
|
||||
Thread.Sleep(PollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Record the in-flight op, write the in_progress status, and (async only) start polling.</summary>
|
||||
static void BeginTracking(string operation, string argument, Request request,
|
||||
Func<Request, PackageSummary> readPackage, bool subscribePoll)
|
||||
{
|
||||
lock (s_Lock)
|
||||
{
|
||||
s_Request = request;
|
||||
s_Operation = operation;
|
||||
s_Argument = argument;
|
||||
s_ReadPackage = readPackage;
|
||||
s_InProgress = true;
|
||||
}
|
||||
|
||||
WriteStatus(new PackageStatus
|
||||
{
|
||||
Status = "in_progress",
|
||||
Operation = operation,
|
||||
Argument = argument,
|
||||
StartedAt = NowIso(),
|
||||
Message = $"{operation} in progress. Poll package_status."
|
||||
});
|
||||
|
||||
if (subscribePoll)
|
||||
SubscribePoll();
|
||||
}
|
||||
|
||||
static void SubscribePoll()
|
||||
{
|
||||
EditorApplication.update -= Poll;
|
||||
EditorApplication.update += Poll;
|
||||
}
|
||||
|
||||
/// <summary>Update-loop poller for async mode (and a timed-out sync wait): finalize when done.</summary>
|
||||
static void Poll() => TryFinalize();
|
||||
|
||||
/// <summary>
|
||||
/// If the tracked request has completed, write its final status, clear tracking, and return the
|
||||
/// status; otherwise return null. Main-thread only (reads Request members); guarded so the sync
|
||||
/// waiter and the update-loop poller can't double-finalize.
|
||||
/// </summary>
|
||||
static PackageStatus TryFinalize()
|
||||
{
|
||||
lock (s_Lock)
|
||||
{
|
||||
var request = s_Request;
|
||||
if (request == null)
|
||||
{
|
||||
EditorApplication.update -= Poll;
|
||||
return null;
|
||||
}
|
||||
if (!request.IsCompleted)
|
||||
return null;
|
||||
|
||||
var status = BuildStatus(s_Operation, s_Argument, request, s_ReadPackage);
|
||||
WriteStatus(status);
|
||||
|
||||
s_Request = null;
|
||||
s_Operation = null;
|
||||
s_Argument = null;
|
||||
s_ReadPackage = null;
|
||||
s_InProgress = false;
|
||||
EditorApplication.update -= Poll;
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
static PackageStatus BuildStatus(string operation, string argument, Request request,
|
||||
Func<Request, PackageSummary> readPackage)
|
||||
{
|
||||
var status = new PackageStatus
|
||||
{
|
||||
Operation = operation,
|
||||
Argument = argument,
|
||||
CompletedAt = NowIso(),
|
||||
Manifest = SafeReadManifest()
|
||||
};
|
||||
|
||||
if (request.Status == StatusCode.Success)
|
||||
{
|
||||
status.Status = "completed";
|
||||
status.Success = true;
|
||||
status.RequiresRecompile = true;
|
||||
try { status.Package = readPackage?.Invoke(request); }
|
||||
catch (Exception ex) { Debug.LogWarning($"[pipeline] package result projection failed: {ex.Message}"); }
|
||||
status.Message = $"{operation} completed.";
|
||||
}
|
||||
else
|
||||
{
|
||||
status.Status = "failed";
|
||||
status.Success = false;
|
||||
status.Error = request.Error?.message ?? "Unknown package manager error.";
|
||||
status.Message = $"{operation} failed: {status.Error}";
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
static PackageMutationResponse ToMutationResponse(PackageStatus s, string plan) => new PackageMutationResponse
|
||||
{
|
||||
Success = s.Success,
|
||||
Operation = s.Operation,
|
||||
Argument = s.Argument,
|
||||
Status = s.Status,
|
||||
Applied = s.Success,
|
||||
Plan = plan,
|
||||
Package = s.Package,
|
||||
Manifest = s.Manifest,
|
||||
RequiresRecompile = s.RequiresRecompile,
|
||||
Message = s.Message
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// On load, settle a status file left at "in_progress" by a domain reload (the expected outcome of
|
||||
/// a successful add/remove). The manifest change that triggered the reload has taken effect, so
|
||||
/// the op is recorded completed; the follow-on recompile is observed via recompile_status.
|
||||
/// </summary>
|
||||
static void RecoverInterruptedOperation()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(StatusFile))
|
||||
return;
|
||||
|
||||
var status = JsonConvert.DeserializeObject<PackageStatus>(File.ReadAllText(StatusFile));
|
||||
if (status == null || status.Status != "in_progress")
|
||||
return;
|
||||
|
||||
status.Status = "completed";
|
||||
status.Success = true;
|
||||
status.RequiresRecompile = true;
|
||||
status.CompletedAt = NowIso();
|
||||
status.Manifest = SafeReadManifest();
|
||||
status.Message = $"{status.Operation} completed (domain reload). Manifest updated; poll recompile_status for the recompile.";
|
||||
WriteStatus(status);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[pipeline] package status recovery failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------------------
|
||||
|
||||
static bool IsBusy() => s_InProgress;
|
||||
|
||||
/// <summary>
|
||||
/// Block until <paramref name="request"/> completes, polling its (main-thread-only) state via
|
||||
/// <see cref="RunOnMain"/> while this thread sleeps between checks. Returns false with
|
||||
/// <paramref name="error"/> set on timeout. Used by the read-only registry queries.
|
||||
/// </summary>
|
||||
static bool WaitForCompletion(Request request, out string error)
|
||||
{
|
||||
error = null;
|
||||
var deadline = DateTime.UtcNow.AddSeconds(WaitTimeoutSeconds);
|
||||
while (!RunOnMain(() => request.IsCompleted))
|
||||
{
|
||||
if (DateTime.UtcNow > deadline)
|
||||
{
|
||||
error = $"Timed out after {WaitTimeoutSeconds}s waiting for the package manager.";
|
||||
return false;
|
||||
}
|
||||
Thread.Sleep(PollIntervalMs);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run <paramref name="fn"/> on the Unity main thread. When invoked off-thread (the normal case
|
||||
/// for the waiting commands), it marshals through the live server's dispatcher; when already on
|
||||
/// the main thread (e.g. a direct in-process/test call, or no server running) it runs inline.
|
||||
/// </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 Dictionary<string, string> SafeReadManifest() =>
|
||||
PackageManifest.TryRead(out var deps, out _) ? deps : null;
|
||||
|
||||
static PackageSummary Map(PackageInfo p, bool isInstalled = false) => new PackageSummary
|
||||
{
|
||||
Name = p.name,
|
||||
Version = p.version,
|
||||
DisplayName = p.displayName,
|
||||
Source = p.source.ToString(),
|
||||
ResolvedPath = p.resolvedPath,
|
||||
IsDirectDependency = p.isDirectDependency,
|
||||
IsInstalled = isInstalled
|
||||
};
|
||||
|
||||
/// <summary>Map registry results, flagging which are currently installed.</summary>
|
||||
static List<PackageSummary> MapMarkingInstalled(IEnumerable<PackageInfo> packages)
|
||||
{
|
||||
var installed = new HashSet<string>();
|
||||
foreach (var p in PackageInfo.GetAllRegisteredPackages())
|
||||
installed.Add(p.name);
|
||||
|
||||
var list = new List<PackageSummary>();
|
||||
if (packages != null)
|
||||
foreach (var p in packages)
|
||||
list.Add(Map(p, installed.Contains(p.name)));
|
||||
return list;
|
||||
}
|
||||
|
||||
static string NowIso() => DateTime.UtcNow.ToString("o");
|
||||
|
||||
static void WriteStatus(PackageStatus status)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[Package] Failed to write status file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 248529bee7274235a46b64bf3f03da11
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.PackageManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads the project's UPM manifest (<c>Packages/manifest.json</c>) <c>dependencies</c> as a flat
|
||||
/// name → version map, satisfying CLI-203's "return manifest state". The JSON parsing is split into
|
||||
/// a pure <see cref="ReadDependencies(string)"/> seam so it is unit-testable without a live project;
|
||||
/// <see cref="TryRead"/> resolves the project path and is main-thread only (it reads
|
||||
/// <see cref="Application.dataPath"/>).
|
||||
/// </summary>
|
||||
public static class PackageManifest
|
||||
{
|
||||
/// <summary>Project-relative path to the UPM manifest.</summary>
|
||||
public const string RelativePath = "Packages/manifest.json";
|
||||
|
||||
/// <summary>Absolute path to the manifest for the current project. Main thread only.</summary>
|
||||
public static string ManifestPath
|
||||
{
|
||||
get
|
||||
{
|
||||
var projectRoot = Path.GetDirectoryName(Application.dataPath);
|
||||
return Path.Combine(projectRoot, "Packages", "manifest.json");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the <c>dependencies</c> object of a manifest.json document into a name → version map.
|
||||
/// Returns an empty map for null/empty input or a manifest without dependencies.
|
||||
/// </summary>
|
||||
public static Dictionary<string, string> ReadDependencies(string manifestJson)
|
||||
{
|
||||
var result = new Dictionary<string, string>();
|
||||
if (string.IsNullOrWhiteSpace(manifestJson))
|
||||
return result;
|
||||
|
||||
var root = JObject.Parse(manifestJson);
|
||||
if (root["dependencies"] is JObject deps)
|
||||
{
|
||||
foreach (var kv in deps)
|
||||
result[kv.Key] = kv.Value?.ToString();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read the current project's manifest dependencies. Returns false with
|
||||
/// <paramref name="error"/> set when the manifest is missing or unreadable.
|
||||
/// </summary>
|
||||
public static bool TryRead(out Dictionary<string, string> dependencies, out string error)
|
||||
{
|
||||
dependencies = null;
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
var path = ManifestPath;
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
error = $"manifest not found at {path}";
|
||||
return false;
|
||||
}
|
||||
|
||||
dependencies = ReadDependencies(File.ReadAllText(path));
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0401cb232d5143bbb185ac248086a09f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.PackageManager
|
||||
{
|
||||
/// <summary>
|
||||
/// One package in a list/search/add result — a JSON-friendly projection of
|
||||
/// <c>UnityEditor.PackageManager.PackageInfo</c> (CLI-203).
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class PackageSummary
|
||||
{
|
||||
[JsonProperty("name")] public string Name { get; set; }
|
||||
[JsonProperty("version")] public string Version { get; set; }
|
||||
[JsonProperty("displayName")] public string DisplayName { get; set; }
|
||||
|
||||
/// <summary>Resolved source: Registry, Embedded, Local, Git, BuiltIn, LocalTarball, Unknown.</summary>
|
||||
[JsonProperty("source")] public string Source { get; set; }
|
||||
|
||||
[JsonProperty("resolvedPath")] public string ResolvedPath { get; set; }
|
||||
|
||||
/// <summary>True when the package is a direct manifest dependency (vs. a transitive one).</summary>
|
||||
[JsonProperty("isDirectDependency")] public bool IsDirectDependency { get; set; }
|
||||
|
||||
/// <summary>True when the package is currently installed/resolved in the project (vs. only
|
||||
/// available in the registry). Always true for an installed-scope listing.</summary>
|
||||
[JsonProperty("isInstalled")] public bool IsInstalled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronous result of <c>package_list</c> (CLI-203). The command returns the full result in one
|
||||
/// call for every scope — <c>installed</c> reads the resolved set inline; <c>available</c>/<c>all</c>
|
||||
/// run a registry query and block until it completes — so listing never uses the trigger-then-poll
|
||||
/// path that the mutating commands do.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class PackageListResponse
|
||||
{
|
||||
[JsonProperty("success")] public bool Success { get; set; }
|
||||
|
||||
/// <summary>The scope that was listed: installed | available | all.</summary>
|
||||
[JsonProperty("scope")] public string Scope { get; set; }
|
||||
|
||||
[JsonProperty("count")] public int Count { get; set; }
|
||||
|
||||
/// <summary>The packages in the requested scope.</summary>
|
||||
[JsonProperty("packages")] public List<PackageSummary> Packages { get; set; }
|
||||
|
||||
/// <summary>Manifest dependencies (name → version).</summary>
|
||||
[JsonProperty("manifest")] public Dictionary<string, string> Manifest { get; set; }
|
||||
|
||||
[JsonProperty("message")] public string Message { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronous result of <c>package_search</c> (CLI-203). Like <c>package_list</c>, the registry
|
||||
/// query is awaited inside the command, so the matches are returned in one call. Each entry carries
|
||||
/// <see cref="PackageSummary.IsInstalled"/> so callers can tell which results are already installed.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class PackageSearchResponse
|
||||
{
|
||||
[JsonProperty("success")] public bool Success { get; set; }
|
||||
|
||||
/// <summary>The search query (empty when listing all available packages).</summary>
|
||||
[JsonProperty("query")] public string Query { get; set; }
|
||||
|
||||
[JsonProperty("count")] public int Count { get; set; }
|
||||
|
||||
/// <summary>The matching packages available in the registry.</summary>
|
||||
[JsonProperty("packages")] public List<PackageSummary> Packages { get; set; }
|
||||
|
||||
[JsonProperty("message")] public string Message { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persisted status of the last async mutating operation (add / remove / resolve), written to a Temp
|
||||
/// status file that survives the domain reload an add/remove/resolve triggers (CLI-203). Read back
|
||||
/// verbatim by <c>package_status</c> so a caller can poll an async op — or validate one whose
|
||||
/// synchronous reply was lost to the reload.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class PackageStatus
|
||||
{
|
||||
/// <summary>idle | in_progress | completed | failed.</summary>
|
||||
[JsonProperty("status")] public string Status { get; set; }
|
||||
|
||||
/// <summary>add | remove | resolve.</summary>
|
||||
[JsonProperty("operation")] public string Operation { get; set; }
|
||||
|
||||
/// <summary>The operation's subject (identifier/name), when applicable.</summary>
|
||||
[JsonProperty("argument")] public string Argument { get; set; }
|
||||
|
||||
[JsonProperty("success")] public bool Success { get; set; }
|
||||
|
||||
[JsonProperty("error")] public string Error { get; set; }
|
||||
|
||||
[JsonProperty("message")] public string Message { get; set; }
|
||||
|
||||
/// <summary>The operation triggers a recompile/domain reload; poll <c>recompile_status</c>.</summary>
|
||||
[JsonProperty("requiresRecompile")] public bool RequiresRecompile { get; set; }
|
||||
|
||||
/// <summary>The added package (add only); null for remove/resolve.</summary>
|
||||
[JsonProperty("package")] public PackageSummary Package { get; set; }
|
||||
|
||||
/// <summary>Manifest dependencies (name → version) after the operation.</summary>
|
||||
[JsonProperty("manifest")] public Dictionary<string, string> Manifest { get; set; }
|
||||
|
||||
[JsonProperty("startedAt")] public string StartedAt { get; set; }
|
||||
[JsonProperty("completedAt")] public string CompletedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a mutating package command — <c>package_add</c> / <c>package_remove</c> /
|
||||
/// <c>package_resolve</c> (CLI-203). These are dual-mode: by default the call returns
|
||||
/// <c>in_progress</c> immediately (poll <c>package_status</c>); with <c>wait=true</c> it blocks and
|
||||
/// this carries the final <c>completed</c>/<c>failed</c> outcome. It reports the <c>confirm</c>/<c>dry_run</c>
|
||||
/// gate via <see cref="DryRun"/> / <see cref="Applied"/>. A successful add/remove
|
||||
/// leaves a recompile/domain reload in flight (<see cref="RequiresRecompile"/>) — poll
|
||||
/// <c>recompile_status</c> to wait for the editor to be ready.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class PackageMutationResponse
|
||||
{
|
||||
[JsonProperty("success")] public bool Success { get; set; }
|
||||
|
||||
/// <summary>add | remove | resolve.</summary>
|
||||
[JsonProperty("operation")] public string Operation { get; set; }
|
||||
|
||||
/// <summary>The operation's subject (identifier/name), when applicable.</summary>
|
||||
[JsonProperty("argument")] public string Argument { get; set; }
|
||||
|
||||
/// <summary>in_progress | completed | dry_run | rejected | failed | busy.</summary>
|
||||
[JsonProperty("status")] public string Status { get; set; }
|
||||
|
||||
/// <summary>True only when the operation actually mutated the project.</summary>
|
||||
[JsonProperty("applied")] public bool Applied { get; set; }
|
||||
|
||||
[JsonProperty("dryRun")] public bool DryRun { get; set; }
|
||||
|
||||
/// <summary>Human-readable description of the intended/performed change.</summary>
|
||||
[JsonProperty("plan")] public string Plan { get; set; }
|
||||
|
||||
/// <summary>The operation leaves a recompile/domain reload in flight; poll <c>recompile_status</c>.</summary>
|
||||
[JsonProperty("requiresRecompile")] public bool RequiresRecompile { get; set; }
|
||||
|
||||
/// <summary>The added package (add only); null for remove/resolve.</summary>
|
||||
[JsonProperty("package")] public PackageSummary Package { get; set; }
|
||||
|
||||
/// <summary>Manifest dependencies (name → version) after the operation.</summary>
|
||||
[JsonProperty("manifest")] public Dictionary<string, string> Manifest { get; set; }
|
||||
|
||||
[JsonProperty("message")] public string Message { get; set; }
|
||||
|
||||
public static PackageMutationResponse InProgress(string operation, string argument, string plan) =>
|
||||
new PackageMutationResponse
|
||||
{
|
||||
Success = true,
|
||||
Operation = operation,
|
||||
Argument = argument,
|
||||
Status = "in_progress",
|
||||
Applied = true,
|
||||
Plan = plan,
|
||||
Message = $"{operation} started. Poll package_status until status is 'completed' or 'failed'."
|
||||
};
|
||||
|
||||
public static PackageMutationResponse Busy(string operation) =>
|
||||
new PackageMutationResponse
|
||||
{
|
||||
Success = false,
|
||||
Operation = operation,
|
||||
Status = "busy",
|
||||
Message = "Another package operation is in progress. Poll package_status, then retry."
|
||||
};
|
||||
|
||||
public static PackageMutationResponse DryRunPreview(string operation, string argument, string plan, string message) =>
|
||||
new PackageMutationResponse
|
||||
{
|
||||
Success = true,
|
||||
Operation = operation,
|
||||
Argument = argument,
|
||||
Status = "dry_run",
|
||||
DryRun = true,
|
||||
Plan = plan,
|
||||
Message = message
|
||||
};
|
||||
|
||||
public static PackageMutationResponse Rejected(string operation, string argument, string message) =>
|
||||
new PackageMutationResponse
|
||||
{
|
||||
Success = false,
|
||||
Operation = operation,
|
||||
Argument = argument,
|
||||
Status = "rejected",
|
||||
Message = message
|
||||
};
|
||||
|
||||
public static PackageMutationResponse Failed(string operation, string argument, string message) =>
|
||||
new PackageMutationResponse
|
||||
{
|
||||
Success = false,
|
||||
Operation = operation,
|
||||
Argument = argument,
|
||||
Status = "failed",
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8fa665b51bbb4f2d8543fc02de12c377
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Commands for controlling Unity Editor play mode state.
|
||||
/// Enables automation workflows to control Editor play/stop/pause from CLI tools.
|
||||
/// </summary>
|
||||
public static class PlayModeCommands
|
||||
{
|
||||
// TODO Pipeline: should all commands have name with namespace: editor.play build.generate instead of using _
|
||||
|
||||
[CliCommand("editor_play", "Enter Unity Editor play mode")]
|
||||
public static string EnterPlayMode()
|
||||
{
|
||||
if (EditorApplication.isPlaying)
|
||||
{
|
||||
Debug.LogWarning("Editor is already in play mode");
|
||||
return "Already in play mode";
|
||||
}
|
||||
|
||||
EditorApplication.isPlaying = true;
|
||||
return "Entered play mode";
|
||||
}
|
||||
|
||||
[CliCommand("editor_stop", "Exit Unity Editor play mode")]
|
||||
public static string ExitPlayMode()
|
||||
{
|
||||
if (!EditorApplication.isPlaying)
|
||||
{
|
||||
Debug.LogWarning("Editor is already in edit mode");
|
||||
return "Already in edit mode";
|
||||
}
|
||||
|
||||
EditorApplication.isPlaying = false;
|
||||
return "Exited play mode";
|
||||
}
|
||||
|
||||
[CliCommand("editor_pause", "Pause Unity Editor play mode")]
|
||||
public static string PausePlayMode()
|
||||
{
|
||||
if (!EditorApplication.isPlaying)
|
||||
{
|
||||
Debug.LogWarning("Cannot pause - Editor is not in play mode");
|
||||
return "Cannot pause - not in play mode";
|
||||
}
|
||||
|
||||
EditorApplication.isPaused = !EditorApplication.isPaused;
|
||||
string state = EditorApplication.isPaused ? "paused" : "unpaused";
|
||||
return $"Play mode {state}";
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c86c7028f3fb8f24691d8a3a207f53ff
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1e6ed0649e884a59b7403a2f28a87644
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user