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
{
///
/// Bridges agent-supplied JSON values and a '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 : [r, g, b, a] (floats 0–1) or a "#RRGGBB"/"#RRGGBBAA" hex string
/// - Vector : [x, y, z, w]
/// - Texture (TexEnv) : an object (resolved + confined to the authoring
/// root), or null to clear
///
/// Reads return the same shapes (Color as [r,g,b,a], Vector as [x,y,z,w], Texture as
/// { texture: ObjectRef } or null) so a read round-trips back through a write.
///
internal static class MaterialValueConverter
{
///
/// Read a single shader property's current value off the material into a plain object suitable
/// for JSON serialization. The shape matches the .
///
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;
}
}
///
/// Apply a JSON value to a single shader property on the material, converting by the shader's
/// declared property type. Throws when the supplied
/// JSON shape does not match the property's type (so the caller can record it in
/// unknown[] with a reason). Texture refs are resolved via
/// and confined to the authoring root; an out-of-root or unresolved ref throws
/// (a hard failure — never silently dropped).
///
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();
}
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();
if (asDouble != Math.Truncate(asDouble))
throw new MaterialPropertyTypeMismatch(
$"{property}: expected Int (a whole number), got the non-integer value {value}.");
return value.ToObject();
}
///
/// Parse a Color from either [r,g,b,a] (3 or 4 floats) or a hex string
/// ("#RRGGBB" / "#RRGGBBAA", leading '#' optional). Default alpha is 1.0.
///
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(), 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);
}
///
/// Resolve a texture property value: an explicit null clears it, otherwise the value must
/// be an that resolves to a 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).
///
private static Texture ToTexture(string property, JToken value)
{
if (value == null || value.Type == JTokenType.Null)
return null;
// ReadValue returns textures as { "texture": } 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();
}
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;
}
///
/// 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.
///
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;
}
/// Map a to the public type label used in results.
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();
}
}
}
///
/// 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 unknown[] with the message as the
/// reason, rather than failing the whole call.
///
internal sealed class MaterialPropertyTypeMismatch : Exception
{
public MaterialPropertyTypeMismatch(string message) : base(message) { }
}
}