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.
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<float></c> on a <c>JsonValue<int></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;
}
}
}