Editor/Prism/Serialization/ValueCodec.cs

Serializer and codec for editor Prism values. Defines TextureValue for texture references and ValueCodec which converts between boxed CLR values (floats, vectors, colors, enums, textures, JSON) and JsonNode, coerces/shapes values to shader types, parses numbers and colors, and helps with equality and descriptions.

File Access
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
using System.Globalization;

namespace Editor.Prism.Serialization;

/// <summary>
/// A texture reference as it appears in a document: an asset path plus the import intent that decides
/// how the sampler is generated. Stored as an object rather than a bare string so colour space and
/// processor survive a round-trip.
/// </summary>
public sealed record TextureValue
{
	/// <summary>Relative asset path, e.g. <c>materials/dev/white_color.tga</c>.</summary>
	public string Path { get; init; }

	/// <summary>How the texture is read: <c>Srgb</c> or <c>Linear</c>.</summary>
	public string ColorSpace { get; init; } = "Srgb";

	/// <summary>Import processor name, e.g. <c>None</c>, <c>NormalizeNormals</c>.</summary>
	public string Processor { get; init; } = "None";

	/// <summary>True when no asset is referenced.</summary>
	public bool IsEmpty => string.IsNullOrWhiteSpace( Path );

	/// <summary>True when the texture should be sampled through an sRGB view.</summary>
	public bool IsSrgb => string.Equals( ColorSpace, "Srgb", StringComparison.OrdinalIgnoreCase );

	/// <summary>Emit the document shape: <c>{ path, colorSpace, processor }</c>.</summary>
	public JsonObject ToJson()
	{
		var json = new JsonObject { ["path"] = Path };

		if ( !string.IsNullOrEmpty( ColorSpace ) && ColorSpace != "Srgb" ) json["colorSpace"] = ColorSpace;
		if ( !string.IsNullOrEmpty( Processor ) && Processor != "None" ) json["processor"] = Processor;

		return json;
	}

	/// <summary>Read the document shape. A bare string is accepted as a path-only descriptor.</summary>
	public static TextureValue From( JsonNode node )
	{
		if ( node is null ) return null;

		if ( node is JsonValue value && value.TryGetValue<string>( out var path ) )
		{
			return new TextureValue { Path = path };
		}

		if ( node is not JsonObject obj ) return null;

		return new TextureValue
		{
			Path = ValueCodec.StringOf( obj["path"] ),
			ColorSpace = ValueCodec.StringOf( obj["colorSpace"] ) ?? "Srgb",
			Processor = ValueCodec.StringOf( obj["processor"] ) ?? "None"
		};
	}

	/// <inheritdoc/>
	public override string ToString() => IsEmpty ? "(no texture)" : Path;
}

/// <summary>
/// Typed literal encoding: the bridge between the boxed values the model stores in port inline slots
/// and parameter defaults, and the JSON a document holds.
/// <para>
/// Every float goes out through <see cref="Number(float)"/>, which formats with <c>"R"</c> so a value
/// read back is bit-identical to the one written. That is what makes "save an unchanged graph and get
/// a byte-identical file" true, which in turn is what makes the text-diff short-circuit before a
/// recompile trustworthy.
/// </para>
/// </summary>
public static class ValueCodec
{
	// ---------------------------------------------------------------- writing ----

	/// <summary>
	/// Encode a boxed value using the shape implied by its CLR type. Colours become <c>"r,g,b,a"</c>,
	/// vectors become arrays, enums become their declared names, textures become objects.
	/// </summary>
	public static JsonNode Write( object value )
	{
		switch ( value )
		{
			case null:
				return null;
			case bool b:
				return JsonValue.Create( b );
			case int i:
				return JsonValue.Create( i );
			case uint u:
				return JsonValue.Create( u );
			case long l:
				return JsonValue.Create( l );
			case float f:
				return Number( f );
			case double d:
				return Number( (float)d );
			case string s:
				return JsonValue.Create( s );
			case Color c:
				return JsonValue.Create( FormatColor( c ) );
			case Vector2 v2:
				return new JsonArray( Number( v2.x ), Number( v2.y ) );
			case Vector3 v3:
				return new JsonArray( Number( v3.x ), Number( v3.y ), Number( v3.z ) );
			case Vector4 v4:
				return new JsonArray( Number( v4.x ), Number( v4.y ), Number( v4.z ), Number( v4.w ) );
			case TextureValue texture:
				return texture.ToJson();
			case Enum e:
				return JsonValue.Create( e.ToString() );
			case JsonNode json:
				return json.DeepClone();
			case float[] array:
				return Vector( array );
			default:
				return JsonValue.Create( PrismLog.Guard( "encode value", () => value.ToString(), string.Empty ) );
		}
	}

	/// <summary>
	/// Encode a boxed value for a known shader type, coercing it into that type's canonical shape
	/// first. This is the form used for port inline literals and parameter defaults.
	/// </summary>
	public static JsonNode Write( ShaderType type, object value ) => Write( Coerce( value, type ) );

	/// <summary>Format a float exactly, so reading it back yields the same bits.</summary>
	public static JsonNode Number( float value )
	{
		if ( float.IsNaN( value ) ) return JsonValue.Create( "NaN" );
		if ( float.IsPositiveInfinity( value ) ) return JsonValue.Create( "Infinity" );
		if ( float.IsNegativeInfinity( value ) ) return JsonValue.Create( "-Infinity" );

		var text = value.ToString( "R", CultureInfo.InvariantCulture );

		return JsonNode.Parse( text ) ?? JsonValue.Create( 0 );
	}

	/// <summary>Format a component array as a JSON array of exact floats.</summary>
	public static JsonArray Vector( params float[] components )
	{
		var array = new JsonArray();

		foreach ( var component in components ?? Array.Empty<float>() )
		{
			array.Add( Number( component ) );
		}

		return array;
	}

	/// <summary>Format a colour the way the engine does: four exact components separated by commas.</summary>
	public static string FormatColor( Color color ) =>
		string.Join( ",",
			color.r.ToString( "R", CultureInfo.InvariantCulture ),
			color.g.ToString( "R", CultureInfo.InvariantCulture ),
			color.b.ToString( "R", CultureInfo.InvariantCulture ),
			color.a.ToString( "R", CultureInfo.InvariantCulture ) );

	// ---------------------------------------------------------------- reading ----

	/// <summary>
	/// Decode a literal for a known shader type. Returns the type's default rather than throwing when
	/// the JSON is the wrong shape — a corrupt literal must never take a document down.
	/// </summary>
	public static object Read( ShaderType type, JsonNode node )
	{
		TryRead( type, node, out var value );
		return value;
	}

	/// <summary>Decode a literal, reporting whether the JSON actually matched the requested type.</summary>
	public static bool TryRead( ShaderType type, JsonNode node, out object value )
	{
		value = Default( type );

		if ( node is null ) return false;

		if ( type.IsObject )
		{
			if ( type.IsTexture )
			{
				var texture = TextureValue.From( node );

				if ( texture is null ) return false;

				value = texture;
				return true;
			}

			var path = StringOf( node );

			if ( path is null ) return false;

			value = path;
			return true;
		}

		if ( type.IsBoolean && type.IsScalar )
		{
			if ( !TryNumbers( node, out var bits ) || bits.Length == 0 ) return false;

			value = bits[0] != 0f;
			return true;
		}

		if ( type.IsScalar && type.IsIntegral )
		{
			if ( !TryNumbers( node, out var ints ) || ints.Length == 0 ) return false;

			value = (int)ints[0];
			return true;
		}

		if ( type.IsScalar )
		{
			if ( !TryNumbers( node, out var scalars ) || scalars.Length == 0 ) return false;

			value = scalars[0];
			return true;
		}

		if ( type.IsVector || type.IsMatrix )
		{
			var wantsColor = node is JsonValue jv && jv.TryGetValue<string>( out var text ) &&
				text.Contains( ',' );

			if ( wantsColor && TryParseColor( StringOf( node ), out var color ) )
			{
				value = type.Components == 4 ? color : ToComponents( color, type.Components );
				return true;
			}

			if ( !TryNumbers( node, out var numbers ) ) return false;

			value = ToComponents( numbers, type.Components );
			return true;
		}

		return false;
	}

	/// <summary>
	/// Decode a literal with no declared type, guessing from the JSON shape. Used for the inline slots
	/// of an unregistered node, where we know nothing about the port.
	/// </summary>
	public static object ReadUntyped( JsonNode node )
	{
		switch ( node )
		{
			case null:
				return null;
			case JsonArray array:
			{
				var numbers = new float[array.Count];

				for ( int i = 0; i < array.Count; i++ )
				{
					numbers[i] = NumberOf( array[i] );
				}

				return ToComponents( numbers, numbers.Length );
			}
			case JsonObject obj when obj.ContainsKey( "path" ):
				return TextureValue.From( obj );
			case JsonObject obj:
				return obj.DeepClone();
			case JsonValue value:
			{
				if ( value.TryGetValue<bool>( out var b ) ) return b;
				if ( value.TryGetValue<int>( out var i ) ) return i;

				if ( value.TryGetValue<string>( out var s ) )
				{
					if ( TryParseColor( s, out var color ) ) return color;

					return s;
				}

				// Anything numeric that was not an int, whatever CLR type is behind it.
				if ( IsNumber( value ) ) return NumberOf( value );

				return null;
			}
			default:
				return null;
		}
	}

	/// <summary>Parse the engine's <c>"r,g,b,a"</c> colour form. Accepts three or four components.</summary>
	public static bool TryParseColor( string text, out Color color )
	{
		color = Color.White;

		if ( string.IsNullOrWhiteSpace( text ) ) return false;

		var parts = text.Split( ',', StringSplitOptions.TrimEntries );

		if ( parts.Length is < 3 or > 4 ) return false;

		var values = new float[4];
		values[3] = 1f;

		for ( int i = 0; i < parts.Length; i++ )
		{
			if ( !float.TryParse( parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out values[i] ) )
			{
				return false;
			}
		}

		color = new Color( values[0], values[1], values[2], values[3] );
		return true;
	}

	/// <summary>The string behind a JSON value, or null when it is not a string.</summary>
	public static string StringOf( JsonNode node )
	{
		if ( node is not JsonValue value ) return null;

		return value.TryGetValue<string>( out var text ) ? text : null;
	}

	/// <summary>
	/// The number behind a JSON value, tolerating numbers written as strings.
	/// <para>
	/// Every numeric backing has to be tried by hand. A <see cref="JsonValue"/> parsed from text wraps a
	/// <c>JsonElement</c> and converts to anything numeric, but one built in memory wraps the exact CLR
	/// type it was created from — and <c>TryGetValue&lt;float&gt;</c> on a <c>JsonValue&lt;int&gt;</c>
	/// returns <b>false</b>. Documents reach us both ways: parsed from disk, and handed over as a live
	/// <c>JsonObject</c> by the asset system or by a migration step that synthesised it. Asking for only
	/// one type would silently read every number in the second kind as zero.
	/// </para>
	/// </summary>
	public static float NumberOf( JsonNode node, float fallback = 0f )
	{
		if ( node is not JsonValue value ) return fallback;

		if ( value.TryGetValue<float>( out var f ) ) return f;
		if ( value.TryGetValue<double>( out var d ) ) return (float)d;
		if ( value.TryGetValue<int>( out var i ) ) return i;
		if ( value.TryGetValue<long>( out var l ) ) return l;
		if ( value.TryGetValue<uint>( out var u ) ) return u;
		if ( value.TryGetValue<ulong>( out var ul ) ) return ul;
		if ( value.TryGetValue<decimal>( out var m ) ) return (float)m;
		if ( value.TryGetValue<bool>( out var b ) ) return b ? 1f : 0f;

		if ( value.TryGetValue<string>( out var text ) )
		{
			if ( float.TryParse( text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed ) )
			{
				return parsed;
			}

			return text switch
			{
				"NaN" => float.NaN,
				"Infinity" => float.PositiveInfinity,
				"-Infinity" => float.NegativeInfinity,
				_ => fallback
			};
		}

		return fallback;
	}

	// ---------------------------------------------------------------- shaping ----

	/// <summary>The zero value of a shader type, in the boxed shape the model stores.</summary>
	public static object Default( ShaderType type )
	{
		if ( type.IsTexture ) return new TextureValue();
		if ( type.IsObject ) return string.Empty;
		if ( type.IsBoolean && type.IsScalar ) return false;
		if ( type.IsScalar && type.IsIntegral ) return 0;
		if ( type.IsScalar ) return 0f;

		return type.Components switch
		{
			2 => Vector2.Zero,
			3 => Vector3.Zero,
			4 => new Vector4( 0f, 0f, 0f, 0f ),
			_ => 0f
		};
	}

	/// <summary>
	/// Reshape a boxed value into the canonical form for a type: widening a scalar into a vector by
	/// splat, truncating a wider vector, and converting between colours and vectors.
	/// </summary>
	public static object Coerce( object value, ShaderType type )
	{
		// There is no such thing as a value of type void, so there is nothing to keep. Saying so here
		// rather than letting it fall through the vector path keeps encoding a void slot idempotent.
		if ( type.IsVoid ) return Default( type );

		if ( value is null ) return Default( type );

		if ( type.IsTexture )
		{
			return value switch
			{
				TextureValue texture => texture,
				string path => new TextureValue { Path = path },
				_ => new TextureValue()
			};
		}

		if ( type.IsObject ) return value as string ?? string.Empty;

		if ( type.IsBoolean && type.IsScalar )
		{
			return value switch
			{
				bool b => b,
				float f => f != 0f,
				int i => i != 0,
				_ => false
			};
		}

		if ( type.IsScalar && type.IsIntegral )
		{
			return value switch
			{
				int i => i,
				float f => (int)f,
				bool b => b ? 1 : 0,
				_ => 0
			};
		}

		var components = ToFloats( value );

		if ( components.Length == 0 ) return Default( type );

		if ( type.IsScalar ) return components[0];

		// Keep a colour a colour: it is what tells the writer to use the "r,g,b,a" form.
		if ( value is Color && type.Components == 4 ) return value;

		return ToComponents( components, type.Components );
	}

	/// <summary>Flatten any supported boxed value into its float components.</summary>
	public static float[] ToFloats( object value ) => value switch
	{
		null => Array.Empty<float>(),
		float f => new[] { f },
		double d => new[] { (float)d },
		int i => new[] { (float)i },
		bool b => new[] { b ? 1f : 0f },
		Vector2 v2 => new[] { v2.x, v2.y },
		Vector3 v3 => new[] { v3.x, v3.y, v3.z },
		Vector4 v4 => new[] { v4.x, v4.y, v4.z, v4.w },
		Color c => new[] { c.r, c.g, c.b, c.a },
		float[] array => array,
		string s => TryParseColor( s, out var parsed )
			? new[] { parsed.r, parsed.g, parsed.b, parsed.a }
			: Array.Empty<float>(),
		_ => Array.Empty<float>()
	};

	/// <summary>Box a component array as the vector or scalar type of that width, splatting when short.</summary>
	public static object ToComponents( float[] components, int width )
	{
		if ( components is null || components.Length == 0 ) components = new[] { 0f };

		float At( int index ) =>
			index < components.Length ? components[index] : components.Length == 1 ? components[0] : 0f;

		return width switch
		{
			<= 1 => At( 0 ),
			2 => new Vector2( At( 0 ), At( 1 ) ),
			3 => new Vector3( At( 0 ), At( 1 ), At( 2 ) ),
			_ => new Vector4( At( 0 ), At( 1 ), At( 2 ), components.Length > 3 ? At( 3 ) : 1f )
		};
	}

	/// <summary>Box a colour as the vector type of a given width.</summary>
	public static object ToComponents( Color color, int width ) =>
		ToComponents( new[] { color.r, color.g, color.b, color.a }, width );

	/// <summary>Lower a boxed literal into the IR's constant representation.</summary>
	public static ConstValue ToConst( object value )
	{
		var components = ToFloats( value );

		return components.Length switch
		{
			0 => ConstValue.Zero,
			1 => new ConstValue( components[0], 0, 0, 0 ),
			2 => new ConstValue( components[0], components[1], 0, 0 ),
			3 => new ConstValue( components[0], components[1], components[2], 0 ),
			_ => new ConstValue( components[0], components[1], components[2], components[3] )
		};
	}

	/// <summary>
	/// Value equality across the boxed shapes, so a "did this literal change" test does not report a
	/// change when a <c>float</c> and a one-element vector describe the same thing.
	/// </summary>
	public static bool Equal( object a, object b )
	{
		if ( ReferenceEquals( a, b ) ) return true;
		if ( a is null || b is null ) return false;

		if ( a is TextureValue ta && b is TextureValue tb ) return ta == tb;
		if ( a is string sa && b is string sb ) return string.Equals( sa, sb, StringComparison.Ordinal );
		if ( a is bool ba && b is bool bb ) return ba == bb;

		var fa = ToFloats( a );
		var fb = ToFloats( b );

		if ( fa.Length == 0 && fb.Length == 0 ) return Equals( a, b );
		if ( fa.Length != fb.Length ) return false;

		for ( int i = 0; i < fa.Length; i++ )
		{
			if ( !fa[i].Equals( fb[i] ) ) return false;
		}

		return true;
	}

	/// <summary>A short, human-readable form for inline pills and tooltips.</summary>
	public static string Describe( object value )
	{
		switch ( value )
		{
			case null:
				return "-";
			case bool b:
				return b ? "true" : "false";
			case int i:
				return i.ToString( CultureInfo.InvariantCulture );
			case float f:
				return f.ToString( "0.###", CultureInfo.InvariantCulture );
			case string s:
				return s;
			case TextureValue texture:
				return texture.ToString();
			case Color c:
				return $"{c.r:0.##}, {c.g:0.##}, {c.b:0.##}, {c.a:0.##}";
			default:
			{
				var components = ToFloats( value );

				if ( components.Length == 0 ) return value.ToString();

				return string.Join( ", ", components.Select( x => x.ToString( "0.###", CultureInfo.InvariantCulture ) ) );
			}
		}
	}

	/// <summary>True when a JSON value holds a number, whatever CLR type is behind it.</summary>
	public static bool IsNumber( JsonNode node )
	{
		if ( node is not JsonValue value ) return false;
		if ( value.TryGetValue<bool>( out _ ) ) return false;
		if ( value.TryGetValue<string>( out _ ) ) return false;

		return value.TryGetValue<float>( out _ ) || value.TryGetValue<double>( out _ ) ||
			value.TryGetValue<int>( out _ ) || value.TryGetValue<long>( out _ ) ||
			value.TryGetValue<uint>( out _ ) || value.TryGetValue<ulong>( out _ ) ||
			value.TryGetValue<decimal>( out _ );
	}

	static bool TryNumbers( JsonNode node, out float[] numbers )
	{
		switch ( node )
		{
			case JsonArray array:
			{
				numbers = new float[array.Count];

				for ( int i = 0; i < array.Count; i++ )
				{
					numbers[i] = NumberOf( array[i] );
				}

				return true;
			}
			case JsonValue value when value.TryGetValue<string>( out var text ) && text.Contains( ',' ):
			{
				var parts = text.Split( ',', StringSplitOptions.TrimEntries );
				numbers = new float[parts.Length];

				for ( int i = 0; i < parts.Length; i++ )
				{
					float.TryParse( parts[i], NumberStyles.Float, CultureInfo.InvariantCulture, out numbers[i] );
				}

				return true;
			}
			case JsonValue:
				numbers = new[] { NumberOf( node ) };
				return true;
			default:
				numbers = Array.Empty<float>();
				return false;
		}
	}
}