Editor/Prism/Serialization/PrismDocument.cs

Data model and JSON (de)serialization helpers for Prism editor documents. Defines the on-disk schema records for documents, meta, settings, preview, parameters, nodes, edges, groups, notes and view, plus JsonSerializer options and custom converters for several id structs to read/write them as bare JSON strings.

File Access
using Editor.Prism.Compiler.Backends;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;

namespace Editor.Prism.Serialization;

/// <summary>
/// The on-disk shape of a <c>.prism</c> / <c>.prismfn</c> document, mirroring the schema one for one.
/// <para>
/// Every section carries an <c>x</c> bag so anything a future version writes survives a round-trip
/// through an older editor, and node properties stay as raw JSON so an unregistered node type is
/// re-emitted byte for byte instead of being destroyed on save.
/// </para>
/// </summary>
public sealed record PrismDocument
{
	/// <summary>Document schema version.</summary>
	public int Schema { get; init; } = PrismConstants.DocumentSchemaVersion;

	/// <summary>Stable document id.</summary>
	public string Id { get; init; }

	/// <summary><c>shader</c> or <c>subgraph</c>.</summary>
	public string Kind { get; init; } = PrismConstants.DocumentKindShader;

	/// <summary>Title, description, authorship and timestamps.</summary>
	public DocumentMeta Meta { get; init; }

	/// <summary>Domain, shading model, blend mode, targets and dialect.</summary>
	public DocumentSettings Settings { get; init; }

	/// <summary>Per-graph preview state.</summary>
	public PreviewStateDto Preview { get; init; }

	/// <summary>Blackboard parameters.</summary>
	public List<ParameterDto> Parameters { get; init; } = new();

	/// <summary>Feature / static / dynamic combo declarations.</summary>
	public List<KeywordDto> Keywords { get; init; } = new();

	/// <summary>Node instances, including reroutes and unrecognised types.</summary>
	public List<NodeDto> Nodes { get; init; } = new();

	/// <summary>Connections.</summary>
	public List<EdgeDto> Edges { get; init; } = new();

	/// <summary>Group / comment boxes.</summary>
	public List<GroupDto> Groups { get; init; } = new();

	/// <summary>Sticky notes.</summary>
	public List<NoteDto> Notes { get; init; } = new();

	/// <summary>Last pan and zoom.</summary>
	public ViewDto View { get; init; }

	/// <summary>Forward-compatibility bag. Anything unrecognised at the top level lands here.</summary>
	public JsonObject X { get; init; }
}

/// <summary>The <c>meta</c> object.</summary>
public sealed record DocumentMeta
{
	/// <summary>Display title.</summary>
	public string Title { get; init; }

	/// <summary>Free-text description.</summary>
	public string Description { get; init; }

	/// <summary>Library category for subgraphs.</summary>
	public string Category { get; init; }

	/// <summary>Material Icons glyph name.</summary>
	public string Icon { get; init; }

	/// <summary>Author name.</summary>
	public string Author { get; init; }

	/// <summary>Creation timestamp, UTC.</summary>
	public DateTimeOffset? Created { get; init; }

	/// <summary>Last-modified timestamp, UTC.</summary>
	public DateTimeOffset? Modified { get; init; }

	/// <summary>Version of Prism that last wrote the document.</summary>
	public string EditorVersion { get; init; }
}

/// <summary>The <c>settings</c> object.</summary>
public sealed record DocumentSettings
{
	/// <summary>What the graph is for.</summary>
	public ShaderDomain Domain { get; init; } = ShaderDomain.Surface;

	/// <summary>How a surface graph resolves to pixels.</summary>
	public ShadingModel ShadingModel { get; init; } = ShadingModel.Lit;

	/// <summary>Output blending.</summary>
	public SurfaceBlendMode BlendMode { get; init; } = SurfaceBlendMode.Opaque;

	/// <summary>Triangle culling.</summary>
	public CullMode CullMode { get; init; } = CullMode.Back;

	/// <summary>Declared render passes.</summary>
	public List<string> Modes { get; init; } = new();

	/// <summary>Backend ids to emit on save.</summary>
	public List<string> Targets { get; init; } = new();

	/// <summary>Which HLSL flavour the generated <c>.shader</c> uses.</summary>
	public HlslDialect HlslDialect { get; init; } = HlslDialect.SboxSlang;

	/// <summary>Whether the second UV channel is declared.</summary>
	public bool Uv2 { get; init; }

	/// <summary>Whether back faces are rendered.</summary>
	public bool RenderBackfaces { get; init; }

	/// <summary>Whether generated code gets descriptive temp names and per-node comments.</summary>
	public bool DebugSymbols { get; init; }

	/// <summary>Forward-compatibility bag.</summary>
	public JsonObject X { get; init; }
}

/// <summary>The <c>preview</c> object.</summary>
public sealed record PreviewStateDto
{
	/// <summary>Built-in mesh name.</summary>
	public string Mesh { get; init; } = "Sphere";

	/// <summary>Custom model asset path, when one is chosen.</summary>
	public string Model { get; init; }

	/// <summary>Environment map asset path.</summary>
	public string Envmap { get; init; }

	/// <summary>Whether the ground plane is drawn.</summary>
	public bool ShowGround { get; init; }

	/// <summary>Whether the skybox is drawn.</summary>
	public bool ShowSkybox { get; init; } = true;

	/// <summary>Tint applied to the preview material, as <c>"r,g,b,a"</c>.</summary>
	public string Tint { get; init; }

	/// <summary>Viewport background, as <c>"r,g,b,a"</c>.</summary>
	public string Background { get; init; }

	/// <summary>Camera orbit state.</summary>
	public PreviewCameraDto Camera { get; init; }
}

/// <summary>The <c>preview.camera</c> object.</summary>
public sealed record PreviewCameraDto
{
	/// <summary>Orbit yaw in degrees.</summary>
	public float Yaw { get; init; }

	/// <summary>Orbit pitch in degrees.</summary>
	public float Pitch { get; init; }

	/// <summary>Orbit distance in world units.</summary>
	public float Distance { get; init; } = 150f;
}

/// <summary>One entry of the <c>parameters</c> array.</summary>
public sealed record ParameterDto
{
	/// <summary>Stable parameter id, referenced by nodes.</summary>
	public string Id { get; init; }

	/// <summary>Display name, also the basis of the generated uniform name.</summary>
	public string Name { get; init; }

	/// <summary>Type spelling, e.g. <c>float</c> or <c>Texture2D</c>.</summary>
	public string Type { get; init; }

	/// <summary>Default value, in the typed-literal encoding for the parameter's type.</summary>
	public JsonNode Default { get; init; }

	/// <summary>Render-attribute name, when the parameter is driven at runtime.</summary>
	public string Attribute { get; init; }

	/// <summary>Material-UI hints.</summary>
	public UiDto Ui { get; init; }

	/// <summary>Forward-compatibility bag.</summary>
	public JsonObject X { get; init; }
}

/// <summary>The <c>ui</c> object of a parameter.</summary>
public sealed record UiDto
{
	/// <summary>Which editor to show.</summary>
	public UiControl Control { get; init; }

	/// <summary>Slider minimum.</summary>
	public float? Min { get; init; }

	/// <summary>Slider maximum.</summary>
	public float? Max { get; init; }

	/// <summary>Slider step.</summary>
	public float? Step { get; init; }

	/// <summary>Group heading in the material editor.</summary>
	public string Group { get; init; }

	/// <summary>Sort order within the group.</summary>
	public int Order { get; init; }

	/// <summary>Tooltip text.</summary>
	public string Tooltip { get; init; }

	/// <summary>Dropdown options.</summary>
	public List<string> Options { get; init; }
}

/// <summary>One entry of the <c>keywords</c> array.</summary>
public sealed record KeywordDto
{
	/// <summary>Stable keyword id.</summary>
	public string Id { get; init; }

	/// <summary>Combo name as it appears in the shader, e.g. <c>F_PUDDLES</c>.</summary>
	public string Name { get; init; }

	/// <summary>Feature, static or dynamic.</summary>
	public ComboKind Kind { get; init; } = ComboKind.Feature;

	/// <summary>Value labels, in order.</summary>
	public List<string> Values { get; init; } = new();

	/// <summary>Index of the default value.</summary>
	public int Default { get; init; }

	/// <summary>Group heading in the material editor.</summary>
	public string Group { get; init; }
}

/// <summary>One entry of the <c>nodes</c> array.</summary>
public sealed record NodeDto
{
	/// <summary>Stable node id, unique within the document.</summary>
	public string Id { get; init; }

	/// <summary>Stable node type id.</summary>
	public string Type { get; init; }

	/// <summary>Node-type version, driving per-node migrations.</summary>
	public int V { get; init; } = 1;

	/// <summary>Grid-snapped scene position as <c>[x, y]</c>.</summary>
	public float[] Pos { get; init; }

	/// <summary>Explicit card size as <c>[w, h]</c>. Only written when the user resized the node.</summary>
	public float[] Size { get; init; }

	/// <summary>Node-specific serialized properties, kept as raw JSON so unknown types round-trip.</summary>
	public JsonObject Props { get; init; }

	/// <summary>Port id to typed literal, for unconnected inputs.</summary>
	public JsonObject Inline { get; init; }

	/// <summary>Optional per-node state.</summary>
	public NodeFlagsDto Flags { get; init; }

	/// <summary>Forward-compatibility bag.</summary>
	public JsonObject X { get; init; }
}

/// <summary>The <c>flags</c> object of a node.</summary>
public sealed record NodeFlagsDto
{
	/// <summary>Render a thumbnail on the card.</summary>
	public bool Preview { get; init; }

	/// <summary>Draw the card as a header-only strip.</summary>
	public bool Collapsed { get; init; }

	/// <summary>Exclude from compilation.</summary>
	public bool Disabled { get; init; }

	/// <summary>Keep in view and exclude from auto-layout.</summary>
	public bool Pinned { get; init; }

	/// <summary>True when nothing is set, so the whole object can be omitted.</summary>
	public bool IsEmpty => !Preview && !Collapsed && !Disabled && !Pinned;
}

/// <summary>One end of an edge.</summary>
public sealed record PortRefDto
{
	/// <summary>Node id.</summary>
	public string Node { get; init; }

	/// <summary>Port id.</summary>
	public string Port { get; init; }
}

/// <summary>One entry of the <c>edges</c> array.</summary>
public sealed record EdgeDto
{
	/// <summary>Stable edge id.</summary>
	public string Id { get; init; }

	/// <summary>The producing end.</summary>
	public PortRefDto From { get; init; }

	/// <summary>The consuming end.</summary>
	public PortRefDto To { get; init; }

	/// <summary>Optional routing waypoints as <c>[[x, y], ...]</c>.</summary>
	public float[][] Via { get; init; }

	/// <summary>Per-edge override for the fill value of a padding conversion.</summary>
	public float? Fill { get; init; }
}

/// <summary>One entry of the <c>groups</c> array.</summary>
public sealed record GroupDto
{
	/// <summary>Stable group id.</summary>
	public string Id { get; init; }

	/// <summary>Header text.</summary>
	public string Title { get; init; }

	/// <summary>Body text.</summary>
	public string Description { get; init; }

	/// <summary>Scene rectangle as <c>[x, y, w, h]</c>.</summary>
	public float[] Rect { get; init; }

	/// <summary>Named colour.</summary>
	public string Color { get; init; }

	/// <summary>Stacking order among overlapping groups.</summary>
	public int Layer { get; init; }
}

/// <summary>One entry of the <c>notes</c> array.</summary>
public sealed record NoteDto
{
	/// <summary>Stable note id.</summary>
	public string Id { get; init; }

	/// <summary>Scene position as <c>[x, y]</c>.</summary>
	public float[] Pos { get; init; }

	/// <summary>Size as <c>[w, h]</c>.</summary>
	public float[] Size { get; init; }

	/// <summary>Named colour.</summary>
	public string Color { get; init; }

	/// <summary>Note text.</summary>
	public string Text { get; init; }
}

/// <summary>The <c>view</c> object.</summary>
public sealed record ViewDto
{
	/// <summary>Scene-space centre of the viewport as <c>[x, y]</c>.</summary>
	public float[] Center { get; init; }

	/// <summary>Zoom factor.</summary>
	public float Zoom { get; init; } = 1f;
}

/// <summary>
/// The serializer options every Prism document is read and written with.
/// <para>
/// Deterministic output is a hard requirement: stable key order (declaration order), two-space
/// indent, camelCase keys and enums written as their declared names — the same convention the
/// engine's own resources use.
/// </para>
/// </summary>
public static class PrismJson
{
	/// <summary>
	/// How deep a document may nest before it is refused.
	/// <para>
	/// This has to be the <em>same</em> number the reader uses, and it has to be generous, because a
	/// forward-compatibility <c>x</c> bag holds arbitrary JSON that a future schema — or another
	/// addon — invented. System.Text.Json defaults to 64 on both sides; with a deeper reader than
	/// writer, a document would open fine and then fail to save, which is the one failure mode that
	/// costs the user their work rather than merely their time.
	/// </para>
	/// </summary>
	public const int MaxDepth = 256;

	/// <summary>Options for reading and writing documents.</summary>
	public static readonly JsonSerializerOptions Options = Build();

	static JsonSerializerOptions Build()
	{
		var options = new JsonSerializerOptions
		{
			WriteIndented = true,
			IndentCharacter = ' ',
			IndentSize = PrismConstants.JsonIndentSize,
			MaxDepth = MaxDepth,
			PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
			PropertyNameCaseInsensitive = true,
			DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
			NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
			AllowTrailingCommas = true,
			ReadCommentHandling = JsonCommentHandling.Skip
		};

		options.Converters.Add( new JsonStringEnumConverter( null, true ) );

		// The four id structs are one string each on disk. Without these they round-trip as
		// { "value": "p1", "isValid": true }, which contradicts the schema, breaks the legacy importer's
		// plain-string props, and hides a parameter reference from the clipboard's id remapper.
		options.Converters.Add( new NodeIdJsonConverter() );
		options.Converters.Add( new PortIdJsonConverter() );
		options.Converters.Add( new EdgeIdJsonConverter() );
		options.Converters.Add( new ParamIdJsonConverter() );

		return options;
	}
}

/// <summary>
/// Reads and writes one of Prism's id structs as a bare JSON string.
/// <para>
/// Tolerant on the way in: a plain string is the canonical form, but an object with a <c>value</c>
/// member is also accepted, which is what a document written before these converters existed contains.
/// </para>
/// </summary>
abstract class IdJsonConverter<T> : JsonConverter<T> where T : struct
{
	protected abstract T Parse( string value );
	protected abstract string TextOf( T value );

	/// <inheritdoc/>
	public override T Read( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options )
	{
		switch ( reader.TokenType )
		{
			case JsonTokenType.String:
				return Parse( reader.GetString() );

			case JsonTokenType.Null:
				return default;

			case JsonTokenType.Number:
				return Parse( reader.TryGetInt64( out var number )
					? number.ToString( System.Globalization.CultureInfo.InvariantCulture )
					: null );

			case JsonTokenType.StartObject:
			{
				var text = (string)null;
				var depth = reader.CurrentDepth;

				while ( reader.Read() )
				{
					if ( reader.TokenType == JsonTokenType.EndObject && reader.CurrentDepth == depth ) break;

					if ( reader.TokenType != JsonTokenType.PropertyName ) continue;

					var name = reader.GetString();

					reader.Read();

					if ( string.Equals( name, "value", StringComparison.OrdinalIgnoreCase ) &&
						reader.TokenType == JsonTokenType.String )
					{
						text = reader.GetString();
						continue;
					}

					reader.TrySkip();
				}

				return Parse( text );
			}

			default:
				reader.TrySkip();
				return default;
		}
	}

	/// <inheritdoc/>
	public override void Write( Utf8JsonWriter writer, T value, JsonSerializerOptions options ) =>
		writer.WriteStringValue( TextOf( value ) ?? string.Empty );

	/// <inheritdoc/>
	public override T ReadAsPropertyName( ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options ) =>
		Parse( reader.GetString() );

	/// <inheritdoc/>
	public override void WriteAsPropertyName( Utf8JsonWriter writer, T value, JsonSerializerOptions options ) =>
		writer.WritePropertyName( TextOf( value ) ?? string.Empty );
}

sealed class NodeIdJsonConverter : IdJsonConverter<NodeId>
{
	protected override NodeId Parse( string value ) => NodeId.Parse( value );
	protected override string TextOf( NodeId value ) => value.Value;
}

sealed class PortIdJsonConverter : IdJsonConverter<PortId>
{
	protected override PortId Parse( string value ) => PortId.Parse( value );
	protected override string TextOf( PortId value ) => value.Value;
}

sealed class EdgeIdJsonConverter : IdJsonConverter<EdgeId>
{
	protected override EdgeId Parse( string value ) => EdgeId.Parse( value );
	protected override string TextOf( EdgeId value ) => value.Value;
}

sealed class ParamIdJsonConverter : IdJsonConverter<ParamId>
{
	protected override ParamId Parse( string value ) => ParamId.Parse( value );
	protected override string TextOf( ParamId value ) => value.Value;
}