Editor/Prism/Model/PrismNode.cs

Model types for the Prism editor graph. Declares NodeFlags, NodeChangeKind, IPrismGraph, ValidationContext and the base PrismNode class with port management, change notification, validation hooks and painting/context menu stubs.

Reflection
using Editor.Prism.Compiler;
using Editor.Prism.Core;
using System.ComponentModel;

namespace Editor.Prism.Model;

/// <summary>Optional per-node state, mirrored to the <c>flags</c> object in the document.</summary>
[Flags]
public enum NodeFlags
{
	/// <summary>Nothing special.</summary>
	None = 0,
	/// <summary>Render a thumbnail of this node's value on the card.</summary>
	Preview = 1 << 0,
	/// <summary>Draw the card as a header-only strip.</summary>
	Collapsed = 1 << 1,
	/// <summary>Excluded from compilation; downstream inputs fall back to their inline values.</summary>
	Disabled = 1 << 2,
	/// <summary>Kept in view and excluded from auto-layout.</summary>
	Pinned = 1 << 3
}

/// <summary>What changed about a node. Drives how much work the editor has to redo.</summary>
public enum NodeChangeKind
{
	/// <summary>A literal or property value changed. Recompiles are value-only; topology is unaffected.</summary>
	Value,
	/// <summary>The port set changed. Edges must be revalidated and the card relaid out.</summary>
	Ports,
	/// <summary>A serialized property changed in a way that affects generated code structure.</summary>
	Properties,
	/// <summary>The node moved. Cosmetic; never triggers a recompile.</summary>
	Position,
	/// <summary>The node's flags changed.</summary>
	Flags,
	/// <summary>The node's validity changed; diagnostics must be refreshed.</summary>
	Validity
}

/// <summary>
/// The minimum a node needs to know about the document that owns it.
/// <para>
/// Declared here, in the model, so <see cref="PrismNode"/> never depends on the concrete graph class
/// or on <c>Editor.NodeEditor</c>. The graph implementation lives in a later package and implements
/// this interface alongside <c>Editor.NodeEditor.IGraph</c>.
/// </para>
/// </summary>
public interface IPrismGraph
{
	/// <summary>Stable document id.</summary>
	string DocumentId { get; }

	/// <summary>True when this document is a subgraph (<c>.prismfn</c>) rather than a shader graph.</summary>
	bool IsSubgraph { get; }

	/// <summary>Every node in the document, including reroutes and unknown nodes.</summary>
	IReadOnlyList<PrismNode> Nodes { get; }

	/// <summary>Every resolved connection in the document.</summary>
	IReadOnlyList<Edge> Edges { get; }

	/// <summary>Look up a node by id. Returns null when it does not exist.</summary>
	PrismNode FindNode( NodeId id );

	/// <summary>The edge terminating on an input port, if any.</summary>
	bool TryGetIncomingEdge( NodeId node, PortId port, out Edge edge );

	/// <summary>Every edge leaving an output port.</summary>
	IEnumerable<Edge> GetOutgoingEdges( NodeId node, PortId port );

	/// <summary>Called by a node when something about it changed.</summary>
	void OnNodeChanged( PrismNode node, NodeChangeKind kind );
}

/// <summary>Everything the card painter tells a node about how it is currently being drawn.</summary>
public readonly record struct NodePaintState( bool Selected, bool Hovered, bool Disabled, bool HasError, float Zoom );

/// <summary>
/// Passed to <see cref="PrismNode.OnValidate"/>. Cheap, pure, runs on every graph edit —
/// report problems here rather than throwing, and never mutate the graph.
/// </summary>
public sealed class ValidationContext
{
	/// <summary>Build a validation context for one node.</summary>
	public ValidationContext( PrismNode node, IPrismGraph graph, DiagnosticSink diagnostics )
	{
		Node = node;
		Graph = graph;
		Diagnostics = diagnostics;
	}

	/// <summary>The node being validated.</summary>
	public PrismNode Node { get; }

	/// <summary>The document that owns it.</summary>
	public IPrismGraph Graph { get; }

	/// <summary>Where problems go.</summary>
	public DiagnosticSink Diagnostics { get; }

	/// <summary>True when the named input has an incoming edge.</summary>
	public bool IsConnected( string port )
	{
		var input = Node?.FindInput( PortId.Parse( port ) );
		return input is not null && input.IsConnected;
	}

	/// <summary>The resolved type of a port, or void when it has not been solved yet.</summary>
	public ShaderType TypeOf( string port )
	{
		var found = Node?.FindPort( PortId.Parse( port ) );
		return found?.EffectiveType ?? ShaderType.Void;
	}

	/// <summary>Report an error against this node, optionally against one of its ports.</summary>
	public void Error( string message, string port = null, string code = DiagnosticCode.MissingInput ) =>
		Diagnostics?.Error( code, message, Ref( port ) );

	/// <summary>Report a warning against this node, optionally against one of its ports.</summary>
	public void Warn( string message, string port = null, string code = DiagnosticCode.LossyConversion ) =>
		Diagnostics?.Warn( code, message, Ref( port ) );

	/// <summary>Report an informational note against this node.</summary>
	public void Info( string message, string port = null, string code = DiagnosticCode.SampleLowered ) =>
		Diagnostics?.Info( code, message, Ref( port ) );

	GraphRef Ref( string port ) => string.IsNullOrEmpty( port )
		? GraphRef.ForNode( Node?.Id ?? NodeId.None )
		: GraphRef.ForPort( Node?.Id ?? NodeId.None, PortId.Parse( port ) );
}

/// <summary>
/// The base class every Prism node derives from.
/// <para>
/// A node is pure model: it declares ports, holds serialized properties and emits IR. It knows
/// nothing about widgets, HLSL or Slang. The single method every node must implement is
/// <see cref="Emit"/>, which builds IR through <see cref="EmitContext"/>.
/// </para>
/// </summary>
public abstract class PrismNode
{
	readonly PortCollection _ports = new();

	/// <summary>Build a node and its reflected port set.</summary>
	protected PrismNode()
	{
		Id = NodeId.New();
		BuildPorts();
	}

	// Everything in this block is model plumbing, and every one of these members is hidden from
	// reflection-driven UI on purpose.
	//
	// The Inspector builds a ControlSheet over node.GetSerialized(), and s&box's SerializedObject
	// walks public properties. `Graph` reaches the whole document and `Inputs`/`Outputs` reach ports
	// that hold a `Node` back-reference, so either one closes a cycle:
	//     node -> Graph -> Nodes -> node -> ...
	//     node -> Inputs -> Port.Node -> Inputs -> ...
	// The walk is unbounded, and because it happens inside engine code it is a StackOverflowException
	// that .NET cannot catch: the whole editor dies with no log line and no autosave. Facepunch's own
	// BaseNode hides the equivalent members for exactly this reason.
	//
	// None of this affects saving: PrismSerializer writes id/pos/flags explicitly from the schema and
	// reads node properties through NodeProperties, neither of which consults these attributes.

	/// <summary>Stable id, unique within the document. Minted once and never rewritten.</summary>
	[Hide, Browsable( false ), JsonIgnore]
	public NodeId Id { get; internal set; }

	/// <summary>The document that owns this node. Null while the node is detached.</summary>
	[Hide, Browsable( false ), JsonIgnore]
	public IPrismGraph Graph { get; internal set; }

	/// <summary>Scene position of the card, snapped to the grid by the editor.</summary>
	[Hide, Browsable( false ), JsonIgnore]
	public Vector2 Position { get; set; }

	/// <summary>Optional per-node state.</summary>
	[Hide, Browsable( false ), JsonIgnore]
	public NodeFlags Flags { get; set; }

	/// <summary>Cached reflection metadata for this node's concrete type.</summary>
	[Hide, Browsable( false ), JsonIgnore]
	public NodeDescriptor Descriptor => NodeDescriptors.Describe( GetType() );

	/// <summary>Input ports, in socket order.</summary>
	[Hide, Browsable( false ), JsonIgnore]
	public IReadOnlyList<InputPort> Inputs => _ports.Inputs;

	/// <summary>Output ports, in socket order.</summary>
	[Hide, Browsable( false ), JsonIgnore]
	public IReadOnlyList<OutputPort> Outputs => _ports.Outputs;

	/// <summary>Ports, properties or validity changed. Drives UI invalidation and recompiles.</summary>
	public event Action<PrismNode, NodeChangeKind> Changed;

	/// <summary>Find an input port by id.</summary>
	public InputPort FindInput( PortId id ) => _ports.FindInput( id );

	/// <summary>Find an output port by id.</summary>
	public OutputPort FindOutput( PortId id ) => _ports.FindOutput( id );

	/// <summary>Find a port of either direction by id.</summary>
	public Port FindPort( PortId id ) => _ports.Find( id );

	/// <summary>
	/// Add ports that cannot be expressed as [In]/[Out] properties (variadic, subgraph, mode-dependent).
	/// <para>
	/// Called once from the constructor and again from every <see cref="RebuildPorts"/>. Because the
	/// first call happens before derived field initialisers run, read only what is safe at that point
	/// and call <see cref="RebuildPorts"/> once the node's properties have been deserialized.
	/// </para>
	/// </summary>
	protected virtual void OnDefinePorts( PortBuilder b ) { }

	/// <summary>Cheap, pure, runs on every graph edit. Report problems; never throw.</summary>
	public virtual void OnValidate( ValidationContext ctx ) { }

	/// <summary>Emit IR for every output that was demanded. THE method every node implements.</summary>
	public abstract void Emit( EmitContext ctx );

	/// <summary>Optional custom body painting inside the card (curves, swatches, previews).</summary>
	public virtual void OnPaintBody( Rect rect, NodePaintState state ) { }

	/// <summary>Contribute entries to the node's right-click menu.</summary>
	public virtual void OnContextMenu( Menu menu ) { }

	[ThreadStatic] static int s_notifyDepth;
	static bool s_cycleReported;

	/// <summary>
	/// How deep change notification may nest before it is treated as a cycle. A legitimate edit
	/// notifies a handful of levels at most: model -> graph -> card -> plugs -> layout.
	/// </summary>
	const int MaxNotifyDepth = 32;

	/// <summary>
	/// Raise <see cref="Changed"/>.
	/// <para>
	/// Guarded against re-entrancy. Every change notification in the editor funnels through here, so a
	/// handler that edits the node it is being told about — directly, or via the card, the inspector or
	/// the compile service — would recurse without bound. That is a <c>StackOverflowException</c>, which
	/// .NET makes uncatchable: the editor dies instantly, with no log line and no autosave. A depth cap
	/// turns that into one loud diagnostic and a dropped notification.
	/// </para>
	/// </summary>
	protected void MarkChanged( NodeChangeKind kind = NodeChangeKind.Value )
	{
		if ( s_notifyDepth >= MaxNotifyDepth )
		{
			if ( !s_cycleReported )
			{
				s_cycleReported = true;

				Log.Error( $"Prism: change-notification cycle on '{Descriptor?.Id ?? GetType().Name}' " +
					$"#{Id} ({kind}) — breaking it to keep the editor alive. Please report this stack:" +
					Environment.NewLine + Environment.StackTrace );
			}

			return;
		}

		s_notifyDepth++;

		try
		{
			Changed?.Invoke( this, kind );
			Graph?.OnNodeChanged( this, kind );
		}
		finally
		{
			s_notifyDepth--;
		}
	}

	/// <summary>
	/// Rebuild the port set from reflection plus <see cref="OnDefinePorts"/>. The sanctioned runtime
	/// mutation: ports whose ids survive keep their resolved type and inline value, and edges to ports
	/// that disappear become broken-edge ghosts rather than being silently deleted.
	/// </summary>
	protected void RebuildPorts()
	{
		BuildPorts();
		MarkChanged( NodeChangeKind.Ports );
	}

	/// <summary>
	/// Rebuild the port set from outside the node, after its properties have been deserialized.
	/// <para>
	/// <see cref="RebuildPorts"/> is deliberately <c>protected</c>: a node owns its own port set and
	/// nothing else may reshape it. Loading is the one exception — <see cref="OnDefinePorts"/> first runs
	/// from the constructor, before any deserialized property has been assigned, so a node whose ports
	/// depend on its properties has the wrong ones until someone asks again. The serializer is the only
	/// caller, which is why this is <c>internal</c> rather than public.
	/// </para>
	/// <para>
	/// It raises <see cref="Changed"/> exactly as <see cref="RebuildPorts"/> does, and must: that
	/// notification is what lets the document turn edges to ports that just disappeared into broken-edge
	/// ghosts. Suppressing it here would silently delete those connections instead.
	/// </para>
	/// </summary>
	internal void RebuildPortsAfterLoad() => RebuildPorts();

	void BuildPorts()
	{
		var builder = PortBuilder.FromReflection( GetType() );
		OnDefinePorts( builder );
		_ports.Apply( this, builder );
	}
}