An editor-side Prism node type that represents a node the editor cannot construct or recognise. It preserves the original JsonObject verbatim, reconstructs ports from graph edges so wires still render, exposes reconstructed port lists, and emits errors during validation/compilation while returning invalid IR values for outputs.
using Editor.Prism.Compiler;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
namespace Editor.Prism.Model;
/// <summary>
/// The placeholder a document produces for a node type this editor does not know about — a
/// third-party node whose plugin is not installed, a node from a newer Prism, or a node whose
/// properties failed to deserialize.
/// <para>
/// It holds the <em>original</em> <see cref="JsonObject"/> and re-emits it verbatim, so opening and
/// saving a graph containing an unrecognised node is lossless down to the byte, apart from
/// <c>pos</c> if the user moved the card. Its ports are reconstructed from the edges that reference
/// it, so the wires still draw and the surrounding graph keeps its shape.
/// </para>
/// <para>
/// The built-in editor's equivalent stores the JSON in a string property and re-serializes it
/// lossily, which quietly destroys third-party nodes on every save. This type exists to make that
/// impossible.
/// </para>
/// </summary>
[NodeInfo( Title = "Unknown Node", Category = "Utility", Icon = "help_outline",
Description = "A node type this editor does not recognise. Its data is preserved exactly." )]
public sealed class UnknownNode : PrismNode
{
readonly List<PortId> _inputs = new();
readonly List<PortId> _outputs = new();
/// <summary>Build a detached placeholder. Prefer the two-argument constructor.</summary>
public UnknownNode()
{
TypeId = "unknown";
Raw = new JsonObject();
}
/// <summary>Build a placeholder around the verbatim JSON of a node we could not construct.</summary>
public UnknownNode( string typeId, JsonObject raw, int typeVersion = 1 )
{
TypeId = string.IsNullOrWhiteSpace( typeId ) ? "unknown" : typeId;
Raw = raw?.DeepClone() as JsonObject ?? new JsonObject();
TypeVersion = typeVersion;
}
/// <summary>The stable type id the document asked for, e.g. <c>acme.fancy.customNoise</c>.</summary>
public string TypeId { get; set; }
/// <summary>The node-type version recorded in the document, preserved so a later save keeps it.</summary>
public int TypeVersion { get; set; } = 1;
/// <summary>
/// The complete, untouched node object as it appeared in the document. Everything about a save is
/// driven from this — the serializer never rebuilds an unknown node from its own fields.
/// </summary>
public JsonObject Raw { get; set; }
/// <summary>Why this node could not be constructed. Shown on the card and in the diagnostics panel.</summary>
public string Reason { get; set; } = "This node type is not registered.";
/// <summary>True when the type simply is not installed, as opposed to a node that failed to read.</summary>
public bool IsMissingPlugin { get; set; } = true;
/// <summary>The display title the card shows: the last segment of the type id.</summary>
public string DisplayTitle
{
get
{
if ( string.IsNullOrEmpty( TypeId ) ) return "Unknown";
var dot = TypeId.LastIndexOf( '.' );
var tail = dot >= 0 && dot < TypeId.Length - 1 ? TypeId[( dot + 1 )..] : TypeId;
return NodeDescriptor.Prettify( tail );
}
}
/// <summary>The port ids reconstructed for the input side, in the order they were discovered.</summary>
public IReadOnlyList<PortId> ReconstructedInputs => _inputs;
/// <summary>The port ids reconstructed for the output side, in the order they were discovered.</summary>
public IReadOnlyList<PortId> ReconstructedOutputs => _outputs;
/// <summary>
/// Make sure a port with this id exists, adding it if it does not. Returns true when the port set
/// changed, so the caller can batch a single <see cref="Rebuild"/>.
/// </summary>
public bool EnsurePort( PortId id, PortDirection direction )
{
if ( !id.IsValid ) return false;
var list = direction == PortDirection.Input ? _inputs : _outputs;
if ( list.Contains( id ) ) return false;
list.Add( id );
return true;
}
/// <summary>
/// Rebuild the port set from every edge that references this node. This is what makes an unknown
/// node's wires survive: the ports are inferred from the document's own connectivity.
/// </summary>
public void RebuildFromEdges( IEnumerable<Edge> edges )
{
var changed = false;
if ( edges is not null )
{
foreach ( var edge in edges )
{
if ( edge is null ) continue;
if ( edge.FromNode == Id ) changed |= EnsurePort( edge.FromPort, PortDirection.Output );
if ( edge.ToNode == Id ) changed |= EnsurePort( edge.ToPort, PortDirection.Input );
}
}
if ( !changed && Inputs.Count == _inputs.Count && Outputs.Count == _outputs.Count ) return;
Rebuild();
}
/// <summary>Apply the current reconstructed port list to the live port collection.</summary>
public void Rebuild() => RebuildPorts();
/// <summary>
/// Re-emit the original node object with only <c>pos</c> (and <c>size</c>, when the user resized
/// the card) refreshed. Everything else — <c>props</c>, <c>inline</c>, <c>flags</c>, the <c>x</c>
/// bag and any key a future schema invented — comes straight back out of <see cref="Raw"/>.
/// </summary>
public JsonObject ToJson()
{
var json = Raw?.DeepClone() as JsonObject ?? new JsonObject();
json["pos"] = new JsonArray( JsonValue.Create( Position.x ), JsonValue.Create( Position.y ) );
return json;
}
/// <inheritdoc/>
protected override void OnDefinePorts( PortBuilder b )
{
b.Clear();
// Guarded: the base constructor calls this before the field initialisers of a derived type
// are guaranteed to have run under every compiler we might be built with.
if ( _inputs is not null )
{
foreach ( var id in _inputs )
{
b.Input( id.Value, PortTypeVariable( id ), id.Value, flags: PortFlags.NoInlineEditor );
}
}
if ( _outputs is not null )
{
foreach ( var id in _outputs )
{
b.Output( id.Value, PortTypeVariable( id ), id.Value );
}
}
}
/// <summary>
/// A type variable unique to one port of this node.
/// <para>
/// Every port of a recovered node has an unknown type, but they are unknown <em>independently</em>.
/// Declaring them all as <c>"any"</c> — or with <c>PortFlags.Passthrough</c> — puts them in the
/// solver's shared per-node passthrough group, which is right for a reroute or a bypass and wrong
/// here: a missing-plugin node with a <c>Texture2D</c> input and a <c>float3</c> output would unify
/// the two, bind the output to <c>Texture2D</c> and report two hard errors about a type problem the
/// user did not create. <c>TypeRules.IsTypeVariable</c> treats any unparseable spelling as a fresh
/// variable, so a distinct name per port gives each one its own slot while keeping
/// <c>PortDef.IsGeneric</c> true — which is what keeps <c>PrismGraph.CanConnect</c> permissive at
/// drop time.
/// </para>
/// </summary>
static string PortTypeVariable( PortId id ) => "u_" + id.Value;
/// <inheritdoc/>
public override void OnValidate( ValidationContext ctx )
{
ctx?.Error( $"Node type '{TypeId}' is not registered. {Reason}", null, DiagnosticCode.UnknownNodeType );
}
/// <inheritdoc/>
public override void Emit( EmitContext ctx )
{
if ( ctx is null ) return;
ctx.Error( $"Node type '{TypeId}' is not registered, so it cannot produce a value." );
foreach ( var output in Outputs )
{
ctx.Out( output.Id.Value, IrValue.Invalid );
}
}
/// <inheritdoc/>
public override string ToString() => $"UnknownNode '{TypeId}' ({Id})";
}