Files

189 lines
9.1 KiB
C#

using System;
using System.IO;
using System.Text.RegularExpressions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Unity.Pipeline.Models
{
/// <summary>
/// JSON converter for <see cref="ObjectRef"/> that accepts BOTH a structured object
/// (all six fields via their JSON property names) AND a single canonical string handle.
///
/// The MCP tool schemas intentionally expose object-reference parameters as plain strings, so an
/// agent typically passes e.g. <c>"target": "/Player"</c> or <c>"target": "GlobalObjectId_V1-..."</c>.
/// Without this converter, Newtonsoft's <c>ToObject&lt;ObjectRef&gt;()</c> (used by
/// <c>BasePipelineServer.ExtractCommandParameters</c>) gets a JValue(string), returns null, and the
/// command fails Required-parameter validation. This converter coerces the string into the matching
/// field, mirroring the priority order of <c>ObjectResolver.TryResolve</c> and <c>ObjectRef.ToString()</c>.
///
/// Registered on the class itself via <c>[JsonConverter(typeof(ObjectRefConverter))]</c> so it applies
/// everywhere an <see cref="ObjectRef"/> is (de)serialized — no server or command changes required.
/// </summary>
public class ObjectRefConverter : JsonConverter<ObjectRef>
{
private static readonly Regex s_GuidWithFileId = new Regex(@"^guid:([0-9a-fA-F]+):([0-9]+)$", RegexOptions.Compiled);
private static readonly Regex s_GuidOnly = new Regex(@"^guid:([0-9a-fA-F]+)$", RegexOptions.Compiled);
private static readonly Regex s_InstanceId = new Regex(@"^instanceId:(-?[0-9]+)$", RegexOptions.Compiled);
private static readonly Regex s_PlainInt = new Regex(@"^-?[0-9]+$", RegexOptions.Compiled);
private static readonly Regex s_Hex32 = new Regex(@"^[0-9a-fA-F]{32}$", RegexOptions.Compiled);
public override ObjectRef ReadJson(JsonReader reader, Type objectType, ObjectRef existingValue,
bool hasExistingValue, JsonSerializer serializer)
{
var token = JToken.Load(reader);
switch (token.Type)
{
case JTokenType.Null:
return null;
case JTokenType.String:
return FromString((string)token);
case JTokenType.Integer:
// A bare JSON number is treated as an instance id (e.g. "target": 48184).
return new ObjectRef { InstanceId = token.ToObject<ObjectId>() };
case JTokenType.Object:
return FromObject((JObject)token);
default:
return null;
}
}
public override void WriteJson(JsonWriter writer, ObjectRef value, JsonSerializer serializer)
{
if (value == null)
{
writer.WriteNull();
return;
}
// Write a standard JSON object (matching the [JsonProperty] names on ObjectRef). Done
// manually rather than via serializer.Serialize to avoid re-entering this converter.
writer.WriteStartObject();
writer.WritePropertyName("globalId");
writer.WriteValue(value.GlobalId);
writer.WritePropertyName("path");
writer.WriteValue(value.Path);
writer.WritePropertyName("guid");
writer.WriteValue(value.Guid);
writer.WritePropertyName("fileId");
if (value.FileId.HasValue) writer.WriteValue(value.FileId.Value); else writer.WriteNull();
writer.WritePropertyName("instanceId");
// Route the id through ObjectIdConverter so it serializes as its raw numeric form
// (the 64-bit EntityId on 6000.4+, the int below). Not this converter — no recursion.
if (value.InstanceId.HasValue) serializer.Serialize(writer, value.InstanceId.Value); else writer.WriteNull();
writer.WritePropertyName("hierarchyPath");
writer.WriteValue(value.HierarchyPath);
writer.WriteEndObject();
}
/// <summary>
/// Map a structured JSON object to an ObjectRef by reading each field directly (rather than
/// <c>ToObject&lt;ObjectRef&gt;()</c>, which would recurse back into this converter).
///
/// Keys are matched case-INSENSITIVELY: agents commonly vary the casing (e.g. "instanceID" for
/// instanceId, "fileID" for fileId), and because this custom converter bypasses Newtonsoft's
/// default case-insensitive property binding, an exact-case lookup would otherwise drop the
/// field and yield an empty (silently unresolved) handle.
/// </summary>
private static ObjectRef FromObject(JObject jo)
{
return new ObjectRef
{
GlobalId = Str(jo, "globalId"),
Path = Str(jo, "path"),
Guid = Str(jo, "guid"),
FileId = Long(jo, "fileId"),
InstanceId = ObjId(jo, "instanceId"),
HierarchyPath = Str(jo, "hierarchyPath"),
};
}
/// <summary>Find a property value by name, ignoring case (Newtonsoft's JObject lookup is case-sensitive).</summary>
private static JToken Get(JObject jo, string key)
{
foreach (var p in jo.Properties())
{
if (string.Equals(p.Name, key, StringComparison.OrdinalIgnoreCase))
return p.Value;
}
return null;
}
private static string Str(JObject jo, string key)
{
var t = Get(jo, key);
return t == null || t.Type == JTokenType.Null ? null : t.Value<string>();
}
private static ObjectId? ObjId(JObject jo, string key)
{
var t = Get(jo, key);
return t == null || t.Type == JTokenType.Null ? (ObjectId?)null : t.ToObject<ObjectId>();
}
private static long? Long(JObject jo, string key)
{
var t = Get(jo, key);
return t == null || t.Type == JTokenType.Null ? (long?)null : t.Value<long?>();
}
/// <summary>
/// Parse a single canonical string handle into the matching field. Priority order matches
/// <c>ObjectResolver.TryResolve</c> / <c>ObjectRef.ToString()</c>; a bare name falls back to
/// the hierarchy path (the most common agent input).
/// </summary>
internal static ObjectRef FromString(string s)
{
if (string.IsNullOrWhiteSpace(s))
return null;
s = s.Trim();
if (s.StartsWith("GlobalObjectId_V1", StringComparison.Ordinal))
return new ObjectRef { GlobalId = s };
// "Assets/..." and "Packages/..." (and the bare roots) are asset paths that
// AssetDatabase.LoadMainAssetAtPath can resolve; everything else falls through to the
// hierarchy-path fallback at the end.
if (s.StartsWith("Assets/", StringComparison.Ordinal)
|| s.StartsWith("Packages/", StringComparison.Ordinal)
|| s == "Assets" || s == "Packages")
return new ObjectRef { Path = s };
var guidFile = s_GuidWithFileId.Match(s);
if (guidFile.Success && long.TryParse(guidFile.Groups[2].Value, out var fileId))
return new ObjectRef { Guid = guidFile.Groups[1].Value, FileId = fileId };
var guidOnly = s_GuidOnly.Match(s);
if (guidOnly.Success)
return new ObjectRef { Guid = guidOnly.Groups[1].Value };
var inst = s_InstanceId.Match(s);
if (inst.Success && ObjectId.TryParse(inst.Groups[1].Value, out var prefixedId))
return new ObjectRef { InstanceId = prefixedId };
// A plain integer is an instance id. On 6000.0-6000.3 negative ids are valid (unsaved scene
// objects); on 6000.4+ the id is the unsigned 64-bit EntityId, so a leading '-' won't parse
// and falls through to the hierarchy-path handling below.
if (s_PlainInt.IsMatch(s) && ObjectId.TryParse(s, out var plainId))
return new ObjectRef { InstanceId = plainId };
// Exactly 32 hex chars with no other prefix is a bare asset GUID.
if (s_Hex32.IsMatch(s))
return new ObjectRef { Guid = s };
// A relative string with a file extension (e.g. "Materials/Floor.mat", "Enemy.prefab") is
// most likely an authoring-root-relative asset path — route it to Path so the resolver can
// normalize it under the authoring root. A leading "/" always means a scene hierarchy path,
// and an extension-less string ("Player", "Root/Child") stays one. Dotted GameObject names
// ("Cube.001") land here too, but the resolver falls back to a hierarchy lookup for those.
if (!s.StartsWith("/", StringComparison.Ordinal) && Path.HasExtension(s))
return new ObjectRef { Path = s };
// Leading-"/" canonical paths and bare names resolve as a scene hierarchy path.
return new ObjectRef { HierarchyPath = s };
}
}
}