Files
AIC-Project/Client/PackageLocal/com.unity.pipeline@0.4.0-exp.1/Editor/Commands/Animation/AnimationClipCommands.cs
T

424 lines
19 KiB
C#

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; }
}
}