Editor-side model for node ports in a graph editor. Defines port enums and flags, immutable PortDef declarations built by reflection or programmatically, live Port/InputPort/OutputPort types that carry solver state, a PortBuilder to assemble declarations (including reflection-based extraction), and PortCollection to instantiate live ports and preserve state across rebuilds.
using Editor.Prism.Core;
using System.ComponentModel;
using System.Reflection;
namespace Editor.Prism.Model;
/// <summary>Which side of a node a port lives on.</summary>
public enum PortDirection
{
/// <summary>Consumes a value. At most one incoming edge.</summary>
Input,
/// <summary>Produces a value. Any number of outgoing edges.</summary>
Output
}
/// <summary>Behavioural flags on a port.</summary>
[Flags]
public enum PortFlags
{
/// <summary>Nothing special.</summary>
None = 0,
/// <summary>Leaving this input unconnected with no inline value is an error.</summary>
Required = 1 << 0,
/// <summary>Not drawn on the card. Still connectable programmatically.</summary>
Hidden = 1 << 1,
/// <summary>Never draw an inline value pill for this input.</summary>
NoInlineEditor = 1 << 2,
/// <summary>Part of a variadic group; the node grows another socket as this one is filled.</summary>
Variadic = 1 << 3,
/// <summary>Opts out of type inference — the value passes through unchanged (reroute, custom code).</summary>
Passthrough = 1 << 4,
/// <summary>Drawn in the node's title bar rather than a port row.</summary>
InTitleBar = 1 << 5,
/// <summary>This input accepts more than one incoming edge (variadic sums, subgraph fan-in).</summary>
AllowMultiple = 1 << 6,
/// <summary>Created by <see cref="PortBuilder"/> at runtime rather than by an attribute.</summary>
Dynamic = 1 << 7
}
/// <summary>
/// A reference to one port of one node. This is the property type used by <c>[In]</c> and
/// <c>[Out]</c> declarations, and the shape both ends of an <see cref="Edge"/> serialize as.
/// </summary>
public readonly record struct PortRef( NodeId Node, PortId Port )
{
/// <summary>The unset reference.</summary>
public static readonly PortRef None = default;
/// <summary>True when both halves are set.</summary>
public bool IsValid => Node.IsValid && Port.IsValid;
/// <summary>Build a reference from raw strings.</summary>
public static PortRef Parse( string node, string port ) => new( NodeId.Parse( node ), PortId.Parse( port ) );
/// <inheritdoc/>
public override string ToString() => IsValid ? $"{Node}.{Port}" : "<none>";
}
/// <summary>
/// The immutable declaration of a port: what it is called, what type it claims to be and how it
/// behaves. Produced by reflection over <c>[In]</c>/<c>[Out]</c> properties and then optionally
/// amended by <see cref="PortBuilder"/> inside <c>PrismNode.OnDefinePorts</c>.
/// </summary>
public sealed record PortDef( PortId Id, string DisplayName, string DeclaredType, PortDirection Direction )
{
/// <summary>Optional collapsible group on the card.</summary>
public string Group { get; init; }
/// <summary>Tooltip shown on the handle and the label.</summary>
public string Tooltip { get; init; }
/// <summary>Behavioural flags.</summary>
public PortFlags Flags { get; init; }
/// <summary>Sort key within the node. Ties keep declaration order.</summary>
public int Order { get; init; }
/// <summary>Name of the <c>[In]</c>/<c>[Out]</c> property that declared this port, when there is one.</summary>
public string PropertyName { get; init; }
/// <summary>Name of the <c>[InlineValue]</c> property that supplies the unconnected value, when there is one.</summary>
public string InlineValueProperty { get; init; }
/// <summary>Former ids that must still deserialize into this port.</summary>
public IReadOnlyList<string> FormerIds { get; init; }
/// <summary>True when the declared type is a type variable rather than a concrete spelling.</summary>
public bool IsGeneric => TypeRules.IsTypeVariable( DeclaredType );
/// <summary>The concrete declared type, or <see cref="ShaderType.Void"/> when the port is generic.</summary>
public ShaderType FixedType => ShaderType.Parse( DeclaredType );
/// <summary>True when leaving this input unconnected is an error.</summary>
public bool Required => ( Flags & PortFlags.Required ) != 0;
/// <summary>True when the port should not be drawn.</summary>
public bool Hidden => ( Flags & PortFlags.Hidden ) != 0;
/// <inheritdoc/>
public override string ToString() => $"{Direction} {Id}:{DeclaredType}";
}
/// <summary>A live port on a live node. Carries the declaration plus everything the solver resolves.</summary>
public abstract class Port
{
/// <summary>Build a port from its declaration.</summary>
protected Port( PrismNode node, PortDef def )
{
Node = node;
Def = def;
}
/// <summary>
/// The node this port belongs to.
/// <para>
/// Hidden from reflection-driven UI: this is a back-reference, so a <c>SerializedObject</c> walk
/// that reaches a port would loop <c>port -> Node -> Inputs -> port</c> forever. That is an
/// uncatchable <c>StackOverflowException</c> inside engine code, which kills the whole editor.
/// </para>
/// </summary>
[Hide, Browsable( false ), JsonIgnore]
public PrismNode Node { get; }
/// <summary>The declaration this port was built from.</summary>
public PortDef Def { get; internal set; }
/// <summary>Stable id, unique within the node.</summary>
public PortId Id => Def.Id;
/// <summary>Display label. May be empty for an unlabelled socket.</summary>
public string DisplayName => Def.DisplayName;
/// <summary>The declared type spelling, concrete or generic.</summary>
public string DeclaredType => Def.DeclaredType;
/// <summary>Optional port group.</summary>
public string Group => Def.Group;
/// <summary>Tooltip text.</summary>
public string Tooltip => Def.Tooltip;
/// <summary>Behavioural flags.</summary>
public PortFlags Flags => Def.Flags;
/// <summary>True when leaving this input unconnected is an error.</summary>
public bool Required => Def.Required;
/// <summary>Which side of the node this port is on.</summary>
public abstract PortDirection Direction { get; }
/// <summary>Position within the node's port list. Assigned when the collection is built.</summary>
public int Index { get; internal set; }
/// <summary>
/// The concrete type assigned by the type solver. Void until the first successful solve;
/// for a non-generic port it always ends up equal to <see cref="PortDef.FixedType"/>.
/// </summary>
public ShaderType ResolvedType { get; set; }
/// <summary>The best type we know: the resolved one when solved, otherwise the declared one.</summary>
public ShaderType EffectiveType => ResolvedType.IsVoid ? Def.FixedType : ResolvedType;
/// <inheritdoc/>
public override string ToString() => $"{Node?.Id}.{Id}";
}
/// <summary>An input port. At most one incoming edge unless <see cref="PortFlags.AllowMultiple"/> is set.</summary>
public sealed class InputPort : Port
{
/// <summary>Build an input port.</summary>
public InputPort( PrismNode node, PortDef def ) : base( node, def ) { }
/// <inheritdoc/>
public override PortDirection Direction => PortDirection.Input;
/// <summary>
/// The literal used when nothing is connected. Boxed because it may be any of the value shapes
/// <c>ValueCodec</c> understands; the node's <c>[InlineValue]</c> property is the authored source
/// when <see cref="PortDef.InlineValueProperty"/> is set.
/// </summary>
public object InlineValue { get; set; }
/// <summary>True when an edge terminates on this port.</summary>
public bool IsConnected =>
Node?.Graph is { } graph && graph.TryGetIncomingEdge( Node.Id, Id, out _ );
}
/// <summary>An output port. May fan out to any number of inputs.</summary>
public sealed class OutputPort : Port
{
/// <summary>Build an output port.</summary>
public OutputPort( PrismNode node, PortDef def ) : base( node, def ) { }
/// <inheritdoc/>
public override PortDirection Direction => PortDirection.Output;
/// <summary>True when at least one edge starts at this port.</summary>
public bool IsConnected =>
Node?.Graph is { } graph && graph.GetOutgoingEdges( Node.Id, Id ).Any();
}
/// <summary>
/// Builds the port list for a node: first from reflection over <c>[In]</c>/<c>[Out]</c> properties,
/// then amended by the node's <c>OnDefinePorts</c> override. Ports that cannot be expressed as
/// properties — variadic sockets, subgraph signatures, mode-dependent sets — are added here.
/// </summary>
public sealed class PortBuilder
{
readonly List<PortDef> _inputs = new();
readonly List<PortDef> _outputs = new();
/// <summary>Input declarations, in socket order.</summary>
public IReadOnlyList<PortDef> Inputs => _inputs;
/// <summary>Output declarations, in socket order.</summary>
public IReadOnlyList<PortDef> Outputs => _outputs;
/// <summary>Append an input port.</summary>
public PortBuilder Input( string id, string type = "float", string name = null, string group = null,
PortFlags flags = PortFlags.None, string tooltip = null, int order = 0 )
{
_inputs.Add( new PortDef( PortId.Parse( id ), name ?? id, type ?? "float", PortDirection.Input )
{
Group = group,
Tooltip = tooltip,
Flags = flags | PortFlags.Dynamic,
Order = order
} );
return this;
}
/// <summary>Append an output port.</summary>
public PortBuilder Output( string id, string type = "float", string name = null, string group = null,
PortFlags flags = PortFlags.None, string tooltip = null, int order = 0 )
{
_outputs.Add( new PortDef( PortId.Parse( id ), name ?? id, type ?? "float", PortDirection.Output )
{
Group = group,
Tooltip = tooltip,
Flags = flags | PortFlags.Dynamic,
Order = order
} );
return this;
}
/// <summary>Append a declaration built elsewhere.</summary>
public PortBuilder Add( PortDef def )
{
if ( def is null ) return this;
if ( def.Direction == PortDirection.Input ) _inputs.Add( def );
else _outputs.Add( def );
return this;
}
/// <summary>Remove a port by id from whichever side it is on.</summary>
public PortBuilder Remove( string id )
{
var portId = PortId.Parse( id );
_inputs.RemoveAll( x => x.Id == portId );
_outputs.RemoveAll( x => x.Id == portId );
return this;
}
/// <summary>Change a port's declared type.</summary>
public PortBuilder Retype( string id, string declaredType )
{
Mutate( id, def => def with { DeclaredType = declaredType } );
return this;
}
/// <summary>Change a port's display label.</summary>
public PortBuilder Rename( string id, string displayName )
{
Mutate( id, def => def with { DisplayName = displayName } );
return this;
}
/// <summary>Add flags to a port.</summary>
public PortBuilder SetFlags( string id, PortFlags flags )
{
Mutate( id, def => def with { Flags = def.Flags | flags } );
return this;
}
/// <summary>True when a port with this id exists on either side.</summary>
public bool Has( string id )
{
var portId = PortId.Parse( id );
return _inputs.Any( x => x.Id == portId ) || _outputs.Any( x => x.Id == portId );
}
/// <summary>Drop every declaration. Used by nodes that build their entire signature dynamically.</summary>
public PortBuilder Clear()
{
_inputs.Clear();
_outputs.Clear();
return this;
}
void Mutate( string id, Func<PortDef, PortDef> mutate )
{
var portId = PortId.Parse( id );
for ( int i = 0; i < _inputs.Count; i++ )
{
if ( _inputs[i].Id == portId ) _inputs[i] = mutate( _inputs[i] );
}
for ( int i = 0; i < _outputs.Count; i++ )
{
if ( _outputs[i].Id == portId ) _outputs[i] = mutate( _outputs[i] );
}
}
/// <summary>Build a builder pre-populated with the reflected declarations of a node type.</summary>
public static PortBuilder FromReflection( Type nodeType )
{
var builder = new PortBuilder();
var (inputs, outputs) = Reflect( nodeType );
builder._inputs.AddRange( inputs );
builder._outputs.AddRange( outputs );
return builder;
}
/// <summary>
/// The port declarations implied by a node type's <c>[In]</c>/<c>[Out]</c> properties, in
/// declaration order (base class first). Cached per type; call <see cref="FlushCache"/> on hotload.
/// </summary>
public static (IReadOnlyList<PortDef> Inputs, IReadOnlyList<PortDef> Outputs) Reflect( Type nodeType )
{
if ( nodeType is null ) return ( Array.Empty<PortDef>(), Array.Empty<PortDef>() );
lock ( s_cacheLock )
{
if ( s_cache.TryGetValue( nodeType, out var cached ) ) return cached;
}
var inputs = new List<PortDef>();
var outputs = new List<PortDef>();
var properties = nodeType
.GetProperties( BindingFlags.Public | BindingFlags.Instance | BindingFlags.FlattenHierarchy )
.OrderBy( DeclarationDepth )
.ThenBy( x => x.MetadataToken )
.ToArray();
// Map port name -> the [InlineValue] property that feeds it.
var inlineValues = new Dictionary<string, string>();
foreach ( var property in properties )
{
var inline = property.GetCustomAttribute<InlineValueAttribute>();
if ( inline is null || string.IsNullOrEmpty( inline.PortName ) ) continue;
inlineValues[inline.PortName] = property.Name;
}
foreach ( var property in properties )
{
var formerly = property.GetCustomAttributes<FormerlyKnownAsAttribute>()
.Select( x => x.OldName )
.Where( x => !string.IsNullOrEmpty( x ) )
.ToArray();
if ( property.GetCustomAttribute<InAttribute>() is { } input )
{
inlineValues.TryGetValue( property.Name, out var inlineProperty );
inputs.Add( new PortDef( PortId.Parse( property.Name ), input.Name ?? property.Name,
input.Type ?? "float", PortDirection.Input )
{
Group = input.Group,
Tooltip = input.Tooltip,
Flags = input.Required ? PortFlags.Required : PortFlags.None,
Order = input.Order,
PropertyName = property.Name,
InlineValueProperty = inlineProperty,
FormerIds = formerly.Length > 0 ? formerly : null
} );
}
if ( property.GetCustomAttribute<OutAttribute>() is { } output )
{
outputs.Add( new PortDef( PortId.Parse( property.Name ), output.Name ?? property.Name,
output.Type ?? "float", PortDirection.Output )
{
Group = output.Group,
Tooltip = output.Tooltip,
Order = output.Order,
PropertyName = property.Name,
FormerIds = formerly.Length > 0 ? formerly : null
} );
}
}
var result = ( (IReadOnlyList<PortDef>)StableSort( inputs ), (IReadOnlyList<PortDef>)StableSort( outputs ) );
lock ( s_cacheLock )
{
s_cache[nodeType] = result;
}
return result;
}
/// <summary>Drop the reflection cache. Must run on hotload — <see cref="PortDef"/>s outlive the assembly otherwise.</summary>
public static void FlushCache()
{
lock ( s_cacheLock )
{
s_cache.Clear();
}
}
/// <summary>
/// Sort by explicit order, keeping declaration order for ties, and drop duplicate ids — a derived
/// class that redeclares a base port wins, because its declaration is the more specific one.
/// </summary>
static PortDef[] StableSort( List<PortDef> defs )
{
var deduped = new List<PortDef>( defs.Count );
for ( int i = 0; i < defs.Count; i++ )
{
var later = false;
for ( int j = i + 1; j < defs.Count; j++ )
{
if ( defs[j].Id != defs[i].Id ) continue;
later = true;
break;
}
if ( !later ) deduped.Add( defs[i] );
}
return deduped.OrderBy( x => x.Order ).ToArray();
}
static int DeclarationDepth( PropertyInfo property )
{
var depth = 0;
var type = property.DeclaringType;
while ( type is not null && type != typeof( object ) )
{
depth++;
type = type.BaseType;
}
return depth;
}
static readonly Dictionary<Type, (IReadOnlyList<PortDef> Inputs, IReadOnlyList<PortDef> Outputs)> s_cache = new();
static readonly object s_cacheLock = new();
}
/// <summary>
/// The live ports of one node. Rebuilding preserves the resolved type and inline value of every
/// port whose id survives; ports that disappear leave their edges to be converted into
/// <see cref="BrokenEdge"/> ghosts by the graph, never silently deleted.
/// </summary>
public sealed class PortCollection
{
readonly List<InputPort> _inputs = new();
readonly List<OutputPort> _outputs = new();
/// <summary>Input ports, in socket order.</summary>
public IReadOnlyList<InputPort> Inputs => _inputs;
/// <summary>Output ports, in socket order.</summary>
public IReadOnlyList<OutputPort> Outputs => _outputs;
/// <summary>Find an input port by id.</summary>
public InputPort FindInput( PortId id ) => _inputs.FirstOrDefault( x => x.Id == id );
/// <summary>Find an output port by id.</summary>
public OutputPort FindOutput( PortId id ) => _outputs.FirstOrDefault( x => x.Id == id );
/// <summary>Find a port of either direction by id.</summary>
public Port Find( PortId id ) => (Port)FindInput( id ) ?? FindOutput( id );
/// <summary>
/// Replace the port set with the declarations in <paramref name="builder"/>, carrying over state
/// from ports whose ids survive. Returns the ids that disappeared.
/// <para>
/// Duplicate ids are tolerated rather than fatal: a node whose <c>OnDefinePorts</c> re-declares a
/// reflected port keeps the last declaration, matching the "more specific wins" rule the reflection
/// pass already uses. A malformed node must never take the document down.
/// </para>
/// </summary>
public IReadOnlyList<PortId> Apply( PrismNode node, PortBuilder builder )
{
var removed = new List<PortId>();
var oldInputs = ToLookup( _inputs );
var oldOutputs = ToLookup( _outputs );
_inputs.Clear();
_outputs.Clear();
foreach ( var def in Dedupe( builder.Inputs ) )
{
var port = new InputPort( node, def ) { Index = _inputs.Count };
if ( oldInputs.TryGetValue( def.Id, out var old ) )
{
port.ResolvedType = old.ResolvedType;
port.InlineValue = old.InlineValue;
oldInputs.Remove( def.Id );
}
_inputs.Add( port );
}
foreach ( var def in Dedupe( builder.Outputs ) )
{
var port = new OutputPort( node, def ) { Index = _outputs.Count };
if ( oldOutputs.TryGetValue( def.Id, out var old ) )
{
port.ResolvedType = old.ResolvedType;
oldOutputs.Remove( def.Id );
}
_outputs.Add( port );
}
removed.AddRange( oldInputs.Keys );
removed.AddRange( oldOutputs.Keys );
return removed;
}
/// <summary>Keep the last declaration for each id, preserving declaration order otherwise.</summary>
static List<PortDef> Dedupe( IReadOnlyList<PortDef> defs )
{
var result = new List<PortDef>( defs.Count );
for ( int i = 0; i < defs.Count; i++ )
{
var later = false;
for ( int j = i + 1; j < defs.Count; j++ )
{
if ( defs[j].Id != defs[i].Id ) continue;
later = true;
break;
}
if ( !later ) result.Add( defs[i] );
}
return result;
}
static Dictionary<PortId, T> ToLookup<T>( List<T> ports ) where T : Port
{
var map = new Dictionary<PortId, T>();
foreach ( var port in ports )
{
map[port.Id] = port;
}
return map;
}
}