Files

216 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
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;
using Object = UnityEngine.Object;
namespace Unity.Pipeline.Editor.Commands.Scripts
{
/// <summary>
/// Read and write serialized ([SerializeField] / public) fields on a component or asset through
/// Unity's <see cref="SerializedObject"/> model (CLI-195).
///
/// Why SerializedObject and not plain reflection: it honours Unity's serialization rules (private
/// [SerializeField] fields, property drawers, prefab overrides), marks the object dirty correctly,
/// and integrates with Undo. Writes are wrapped in an <see cref="AuthoringUndoScope"/> so an
/// agent's change reverts as one step.
///
/// Field addressing uses Unity's native SerializedProperty path syntax, so array elements are
/// reachable directly: "myArray.Array.data[2]" sets the third element; "myArray.Array.size" sets
/// the array length. Nested fields use dotted paths ("settings.speed").
/// </summary>
public static class SerializedFieldCommands
{
[CliCommand("set_serialized_field",
"Set a serialized field on a component/asset. Supports primitives, enums, Vector/Color/Rect/Bounds, " +
"object references (value = an ObjectRef: asset by guid/fileId/path or scene object by instanceId/hierarchyPath), " +
"and array elements via 'name.Array.data[i]' (or 'name.Array.size' to resize).")]
public static AuthoringResult SetSerializedField(
[CliArg("target", "Reference to the component or asset to modify (globalId/path/guid/instanceId/hierarchyPath). May be a GameObject when 'component' is given.", Required = true)] ObjectRef target,
[CliArg("field", "SerializedProperty path, e.g. 'speed', 'settings.speed', or 'waypoints.Array.data[0]'.", Required = true)] string field,
[CliArg("value", "JSON value to assign. For object references pass an ObjectRef object (or null to clear). For enums pass the value name.", Required = true)] JToken value,
[CliArg("component", "Component type name on the target GameObject (e.g. 'Rigidbody'). Use when 'target' is a GameObject; omit when 'target' is already a component handle.")] string component = null)
{
if (target == null || target.IsEmpty)
throw new ArgumentException("set_serialized_field 'target' is required.");
if (string.IsNullOrWhiteSpace(field))
throw new ArgumentException("set_serialized_field 'field' is required.");
var obj = ResolveSerializable(target, component);
using (new AuthoringUndoScope($"Set {field}"))
{
// RegisterCompleteObjectUndo captures the pre-change state for revert; the
// SerializedObject below then records the change for prefab/asset dirtying.
Undo.RegisterCompleteObjectUndo(obj, $"Set {field}");
var so = new SerializedObject(obj);
var prop = so.FindProperty(field);
if (prop == null)
throw new ArgumentException(
$"Field '{field}' was not found on '{obj.GetType().Name}'. " +
"Use a SerializedProperty path (e.g. 'speed', 'settings.speed', 'items.Array.data[0]').");
SerializedPropertyConverter.SetValue(prop, value);
so.ApplyModifiedProperties();
EditorUtility.SetDirty(obj);
}
var result = ObjectResolver.Describe(obj) ?? new AuthoringResult { Type = obj.GetType().Name };
return result;
}
[CliCommand("get_serialized_fields",
"Read serialized fields of a component/asset. Returns each top-level field's name, type and value " +
"(object references are returned as re-usable handles). Pass 'field' to read a single SerializedProperty path.")]
public static object GetSerializedFields(
[CliArg("target", "Reference to the component or asset to read (globalId/path/guid/instanceId/hierarchyPath). May be a GameObject when 'component' is given.", Required = true)] ObjectRef target,
[CliArg("field", "Optional single SerializedProperty path to read (e.g. 'speed' or 'items.Array.data[0]'). Omit to read all top-level fields.")] string field = null,
[CliArg("component", "Component type name on the target GameObject (e.g. 'Rigidbody'). Use when 'target' is a GameObject; omit when 'target' is already a component handle.")] string component = null)
{
if (target == null || target.IsEmpty)
throw new ArgumentException("get_serialized_fields 'target' is required.");
var obj = ResolveSerializable(target, component);
var so = new SerializedObject(obj);
if (!string.IsNullOrWhiteSpace(field))
{
var prop = so.FindProperty(field);
if (prop == null)
throw new ArgumentException($"Field '{field}' was not found on '{obj.GetType().Name}'.");
return new
{
type = obj.GetType().Name,
fields = new[] { DescribeProperty(prop) }
};
}
var fields = new List<object>();
var iterator = so.GetIterator();
// enterChildren=true on the first MoveNext to step into the top level; then false to stay
// at the top level and skip nested children (callers drill in via an explicit 'field').
var enterChildren = true;
while (iterator.NextVisible(enterChildren))
{
enterChildren = false;
// m_Script is Unity's hidden back-reference to the MonoScript; not a user field.
if (iterator.propertyPath == "m_Script")
continue;
fields.Add(DescribeProperty(iterator));
}
return new { type = obj.GetType().Name, fields };
}
/// <summary>
/// Resolve a handle to an object that a SerializedObject can wrap: a Component or an asset.
///
/// Two addressing forms (CLI-225):
/// * The handle already points at a Component (or asset) → use it directly. An optional
/// <paramref name="component"/> type name is validated against it as a guard.
/// * The handle points at a GameObject AND <paramref name="component"/> is given → look up
/// the matching component(s) on that GameObject. Exactly one match is used; MULTIPLE
/// same-type components are an error that lists each instanceId so the agent can re-address
/// by instanceId; zero matches is a clear error.
///
/// A GameObject with no <paramref name="component"/> is rejected (a GameObject's "fields" are
/// its components), with a hint to pass --component or a specific component handle.
///
/// This mirrors <see cref="ComponentCommands.ResolveComponent"/>'s GO-path+type pattern, but
/// deliberately errors on MULTIPLE matches (that helper returns the first) so a set/get never
/// silently targets the wrong instance.
/// </summary>
private static Object ResolveSerializable(ObjectRef target, string component = null)
{
if (!ObjectResolver.TryResolve(target, 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. A 'component' here
// is only a constraint to validate against, never a reason to re-fetch (which could pick a
// different same-type instance and ignore the handle).
if (obj is Component directComponent)
{
if (!string.IsNullOrWhiteSpace(component))
{
var requestedType = TypeResolver.ResolveComponentType(component);
if (requestedType == null)
throw new ArgumentException($"Could not resolve component type '{component}'.");
if (!requestedType.IsInstanceOfType(directComponent))
throw new ArgumentException(
$"Target resolves to a {directComponent.GetType().Name}, which is not a '{requestedType.Name}'.");
}
return directComponent;
}
if (obj is GameObject go)
{
if (string.IsNullOrWhiteSpace(component))
throw new ArgumentException(
$"Target '{target}' is a GameObject. Pass --component <TypeName> to pick a component on it, " +
"or target a specific component directly (use its instanceId/globalId), " +
"to read or set serialized fields.");
var componentType = TypeResolver.ResolveComponentType(component);
if (componentType == null)
throw new ArgumentException($"Could not resolve component type '{component}'.");
var matches = go.GetComponents(componentType).Where(c => c != null).ToArray();
if (matches.Length == 0)
throw new ArgumentException(
$"GameObject '{go.name}' has no component of type '{componentType.Name}'.");
if (matches.Length > 1)
{
var sb = new StringBuilder();
sb.Append($"GameObject '{go.name}' has {matches.Length} components of type '{componentType.Name}'. ")
.Append("Re-target a specific one by its instanceId: ");
for (int i = 0; i < matches.Length; i++)
{
if (i > 0) sb.Append(", ");
sb.Append($"instanceId {PipelineUtils.GetObjectId(matches[i])}");
}
sb.Append('.');
throw new ArgumentException(sb.ToString());
}
return matches[0];
}
// Not a Component, not a GameObject — an asset (e.g. ScriptableObject) handled directly.
// A 'component' filter is meaningless for an asset; reject it rather than silently ignore it
// (a supplied component here signals a misrouted or misspelled request).
if (!string.IsNullOrWhiteSpace(component))
throw new ArgumentException(
$"Target '{target}' resolved to a {obj.GetType().Name} (an asset), which has no components. " +
"Omit --component when targeting an asset directly.");
return obj;
}
private static object DescribeProperty(SerializedProperty prop)
{
return new
{
name = prop.name,
path = prop.propertyPath,
propertyType = prop.propertyType.ToString(),
isArray = prop.isArray && prop.propertyType != SerializedPropertyType.String,
arrayLength = (prop.isArray && prop.propertyType != SerializedPropertyType.String) ? prop.arraySize : (int?)null,
value = SerializedPropertyConverter.GetValue(prop)
};
}
}
}