Editor-side model for a node-graph document (Prism). Defines change kinds, metadata, view state, the PrismGraph class that stores nodes, edges, parameters, keywords, groups, notes, settings and indexing for lookups, plus FNV hashing utilities. Implements mutation APIs (add/remove nodes/edges/params/keywords/groups/notes), queries, eventing, suspend/restore event batching, and structure/value hash computation used to detect recompilation needs.
using Editor.Prism.Compiler;
using Editor.Prism.Compiler.Ir;
using Editor.Prism.Core;
using Editor.Prism.Serialization;
namespace Editor.Prism.Model;
/// <summary>What changed about a document. Carried by <see cref="PrismGraph.Changed"/>.</summary>
public enum GraphChangeKind
{
/// <summary>A node was added.</summary>
NodeAdded,
/// <summary>A node was removed.</summary>
NodeRemoved,
/// <summary>A node reported a change about itself.</summary>
NodeChanged,
/// <summary>An edge was added.</summary>
EdgeAdded,
/// <summary>An edge was removed.</summary>
EdgeRemoved,
/// <summary>An edge became a dangling ghost.</summary>
EdgeBroken,
/// <summary>A blackboard parameter was added.</summary>
ParameterAdded,
/// <summary>A blackboard parameter was removed.</summary>
ParameterRemoved,
/// <summary>A blackboard parameter's name, type, default or UI changed.</summary>
ParameterChanged,
/// <summary>A keyword was added.</summary>
KeywordAdded,
/// <summary>A keyword was removed.</summary>
KeywordRemoved,
/// <summary>A keyword changed.</summary>
KeywordChanged,
/// <summary>The group list changed.</summary>
GroupsChanged,
/// <summary>The sticky-note list changed.</summary>
NotesChanged,
/// <summary>Graph settings changed.</summary>
SettingsChanged,
/// <summary>Preview state changed.</summary>
PreviewChanged,
/// <summary>Document metadata changed.</summary>
MetaChanged,
/// <summary>Pan or zoom changed. Cosmetic; never invalidates a compile.</summary>
ViewChanged,
/// <summary>Everything changed — the document was loaded, cleared or restored from a snapshot.</summary>
Reloaded
}
/// <summary>One fine-grained document change, so the editor can react without rebuilding everything.</summary>
public readonly record struct GraphChange( GraphChangeKind Kind )
{
/// <summary>The node involved, when there is one.</summary>
public NodeId Node { get; init; }
/// <summary>The edge involved, when there is one.</summary>
public EdgeId Edge { get; init; }
/// <summary>The parameter or keyword involved, when there is one.</summary>
public ParamId Parameter { get; init; }
/// <summary>For <see cref="GraphChangeKind.NodeChanged"/>: what the node said changed.</summary>
public NodeChangeKind NodeChange { get; init; }
/// <summary>True when this change can affect generated code.</summary>
public bool AffectsCompilation => Kind is not (GraphChangeKind.ViewChanged or GraphChangeKind.MetaChanged
or GraphChangeKind.GroupsChanged or GraphChangeKind.NotesChanged or GraphChangeKind.PreviewChanged)
&& NodeChange != NodeChangeKind.Position;
/// <inheritdoc/>
public override string ToString() => Kind switch
{
GraphChangeKind.NodeChanged => $"{Kind}({Node}, {NodeChange})",
GraphChangeKind.NodeAdded or GraphChangeKind.NodeRemoved => $"{Kind}({Node})",
GraphChangeKind.EdgeAdded or GraphChangeKind.EdgeRemoved or GraphChangeKind.EdgeBroken => $"{Kind}({Edge})",
_ => Kind.ToString()
};
}
/// <summary>Document metadata: the human-facing description of a graph.</summary>
public sealed class GraphMeta
{
/// <summary>Display title.</summary>
public string Title { get; set; }
/// <summary>Free-text description.</summary>
public string Description { get; set; }
/// <summary>Library category, used when a subgraph appears in the node palette.</summary>
public string Category { get; set; }
/// <summary>Material Icons glyph name.</summary>
public string Icon { get; set; }
/// <summary>Author name.</summary>
public string Author { get; set; }
/// <summary>Creation timestamp, UTC.</summary>
public DateTimeOffset? Created { get; set; }
/// <summary>Last-modified timestamp, UTC. Stamped by the serializer on write.</summary>
public DateTimeOffset? Modified { get; set; }
/// <summary>Version of Prism that last wrote the document.</summary>
public string EditorVersion { get; set; } = PrismConstants.EditorVersion;
/// <summary>Deep copy.</summary>
public GraphMeta Clone() => new()
{
Title = Title,
Description = Description,
Category = Category,
Icon = Icon,
Author = Author,
Created = Created,
Modified = Modified,
EditorVersion = EditorVersion
};
/// <inheritdoc/>
public override string ToString() => Title ?? "(untitled)";
}
/// <summary>The saved pan and zoom of the canvas.</summary>
public sealed class ViewState
{
/// <summary>Scene-space centre of the viewport.</summary>
public Vector2 Center { get; set; }
/// <summary>Zoom factor. Clamped to a sane range when applied.</summary>
public float Zoom { get; set; } = 1f;
/// <summary>The zoom clamped into the range the canvas actually supports.</summary>
public float SafeZoom => Math.Clamp( Zoom <= 0f ? 1f : Zoom, 0.05f, 8f );
/// <summary>Deep copy.</summary>
public ViewState Clone() => new() { Center = Center, Zoom = Zoom };
/// <inheritdoc/>
public override string ToString() => $"center {Center} zoom {Zoom:0.###}";
}
/// <summary>
/// The document root: nodes, edges, parameters, keywords, groups, notes, settings, preview state and
/// the saved view, plus the lookup indexes and change events the rest of the editor reacts to.
/// <para>
/// <b>Mutating this class directly does not create an undo entry.</b> UI code must go through
/// <c>Editor.Prism.Undo.GraphMutations</c>, which snapshots the document around every edit. The raw
/// mutators are public because the serializer, the clipboard and the undo stack all need them, and
/// because a model that cannot be built without a UI is not testable.
/// </para>
/// <para>
/// Deliberately free of <c>Editor.NodeEditor</c>: the UI package wraps this in an <c>IGraph</c>
/// adapter. That keeps the whole model, compiler and serializer layer buildable and testable without
/// the node-graph framework, and confines every line of framework contact to one folder.
/// </para>
/// </summary>
public sealed class PrismGraph : ICompilableGraph
{
readonly List<PrismNode> _nodes = new();
readonly Dictionary<NodeId, PrismNode> _nodeById = new();
readonly List<Edge> _edges = new();
readonly Dictionary<EdgeId, Edge> _edgeById = new();
readonly Dictionary<PortRef, List<Edge>> _incoming = new();
readonly Dictionary<PortRef, List<Edge>> _outgoing = new();
readonly Dictionary<NodeId, List<Edge>> _touching = new();
readonly Dictionary<NodeId, Vector2> _nodeSizes = new();
readonly Dictionary<NodeId, JsonObject> _nodeExtras = new();
readonly List<BrokenEdge> _broken = new();
readonly List<Parameter> _parameters = new();
readonly List<Keyword> _keywords = new();
readonly List<GraphGroup> _groups = new();
readonly List<StickyNote> _notes = new();
int _suspend;
bool _suspendedChange;
ulong _structureHash;
ulong _valueHash;
bool _hashesValid;
/// <summary>Build an empty shader graph with a fresh document id.</summary>
public PrismGraph() : this( false ) { }
/// <summary>Build an empty document with a fresh id.</summary>
public PrismGraph( bool isSubgraph )
{
DocumentId = Ids.NewShortId();
IsSubgraph = isSubgraph;
Meta = new GraphMeta { Created = DateTimeOffset.UtcNow };
Settings = new GraphSettings();
Preview = new PreviewState();
View = new ViewState();
}
// ---------------------------------------------------------------- identity ----
/// <summary>Stable document id, minted once and never rewritten.</summary>
public string DocumentId { get; set; }
/// <summary>True when this document is a subgraph (<c>.prismfn</c>) rather than a shader graph.</summary>
public bool IsSubgraph { get; set; }
/// <summary>The document kind string written to disk.</summary>
public string Kind => IsSubgraph ? PrismConstants.DocumentKindSubgraph : PrismConstants.DocumentKindShader;
/// <summary>The asset path this document was last read from or written to. Not serialized.</summary>
public string AssetPath { get; set; }
/// <summary>Human-facing description of the graph.</summary>
public GraphMeta Meta { get; set; }
/// <summary>Domain, shading model, blend mode, targets and dialect.</summary>
public GraphSettings Settings { get; set; }
/// <summary>Per-graph preview state.</summary>
public PreviewState Preview { get; set; }
/// <summary>Saved pan and zoom.</summary>
public ViewState View { get; set; }
/// <summary>Forward-compatibility bag: anything unrecognised at the top level is preserved here.</summary>
public JsonObject X { get; set; }
/// <summary>Monotonic counter bumped by every change. Cheap "did anything happen" check.</summary>
public int Version { get; private set; }
/// <summary>True when the document has unsaved changes.</summary>
public bool IsDirty { get; set; }
// ---------------------------------------------------------------- contents ----
/// <summary>Every node, in document order.</summary>
public IReadOnlyList<PrismNode> Nodes => _nodes;
/// <summary>Every resolved connection, in document order.</summary>
public IReadOnlyList<Edge> Edges => _edges;
/// <summary>Connections that could not be resolved. Drawn as ghosts; never silently dropped.</summary>
public IReadOnlyList<BrokenEdge> BrokenEdges => _broken;
/// <summary>Blackboard parameters, in author order.</summary>
public IReadOnlyList<Parameter> Parameters => _parameters;
/// <summary>Keyword / combo declarations, in author order.</summary>
public IReadOnlyList<Keyword> Keywords => _keywords;
/// <summary>Group boxes, in document order.</summary>
public IReadOnlyList<GraphGroup> Groups => _groups;
/// <summary>Sticky notes, in document order.</summary>
public IReadOnlyList<StickyNote> Notes => _notes;
/// <summary>
/// The terminal node this document compiles to, or null when nothing in the document claims to be
/// one and the compiler should fall back to finding it structurally.
/// <para>
/// A node implementing <see cref="IPrismOutputNode"/> wins, because it declares its own roots and
/// stages rather than being guessed at. Failing that, a node the registry marks as an output — an id
/// under <c>prism.output.</c> or a category starting with "Output" — is used. Ties break on
/// connected-input count and then on id, so the answer is stable across reloads.
/// </para>
/// </summary>
public PrismNode OutputNode => _nodes
.Where( x => x is IPrismOutputNode || GraphQueries.IsOutputNode( x ) )
.OrderByDescending( x => x is IPrismOutputNode ? 1 : 0 )
.ThenByDescending( ConnectedInputCount )
.ThenBy( x => x.Id )
.FirstOrDefault();
int ConnectedInputCount( PrismNode node )
{
if ( node is null ) return 0;
var count = 0;
foreach ( var input in node.Inputs )
{
if ( _incoming.TryGetValue( new PortRef( node.Id, input.Id ), out var edges ) && edges.Count > 0 ) count++;
}
return count;
}
// ------------------------------------------------------ ICompilableGraph ----
// Everything the compiler needs beyond IPrismGraph. Implemented explicitly because the document's own
// shape is richer than the compiler's view of it: settings live in a nested object, and parameters
// and keywords are mutable model classes rather than the immutable slices the compiler reads.
// IReadOnlyList<out T> is covariant, so the parameter and keyword lists project without copying.
/// <inheritdoc/>
ShaderDomain ICompilableGraph.Domain =>
IsSubgraph ? ShaderDomain.Subgraph : Settings?.Domain ?? ShaderDomain.Surface;
/// <inheritdoc/>
ShadingModel ICompilableGraph.ShadingModel => Settings?.ShadingModel ?? ShadingModel.Lit;
/// <inheritdoc/>
SurfaceBlendMode ICompilableGraph.BlendMode => Settings?.BlendMode ?? SurfaceBlendMode.Opaque;
/// <inheritdoc/>
CullMode ICompilableGraph.CullMode => Settings?.CullMode ?? CullMode.Back;
/// <inheritdoc/>
IReadOnlyList<string> ICompilableGraph.Modes => Settings?.Modes ?? (IReadOnlyList<string>)Array.Empty<string>();
/// <inheritdoc/>
bool ICompilableGraph.UsesUv2 => Settings?.Uv2 ?? false;
/// <inheritdoc/>
bool ICompilableGraph.RenderBackfaces => Settings?.RenderBackfaces ?? false;
/// <inheritdoc/>
string ICompilableGraph.Title => Meta?.Title;
/// <inheritdoc/>
string ICompilableGraph.Description => Meta?.Description;
/// <inheritdoc/>
IReadOnlyList<IGraphParameter> ICompilableGraph.Parameters => _parameters;
/// <inheritdoc/>
IReadOnlyList<IGraphKeyword> ICompilableGraph.Keywords => _keywords;
/// <inheritdoc/>
PrismNode ICompilableGraph.OutputNode => OutputNode;
// ---------------------------------------------------------------- events ----
/// <summary>A node was added.</summary>
public event Action<PrismNode> NodeAdded;
/// <summary>A node was removed. Its edges have already been removed when this fires.</summary>
public event Action<PrismNode> NodeRemoved;
/// <summary>A node reported a change about itself.</summary>
public event Action<PrismNode, NodeChangeKind> NodeChanged;
/// <summary>An edge was added.</summary>
public event Action<Edge> EdgeAdded;
/// <summary>An edge was removed.</summary>
public event Action<Edge> EdgeRemoved;
/// <summary>An edge became a dangling ghost.</summary>
public event Action<BrokenEdge> EdgeBroken;
/// <summary>A parameter was added.</summary>
public event Action<Parameter> ParameterAdded;
/// <summary>A parameter was removed.</summary>
public event Action<Parameter> ParameterRemoved;
/// <summary>A parameter's name, type, default or UI hints changed.</summary>
public event Action<Parameter> ParameterChanged;
/// <summary>A keyword was added.</summary>
public event Action<Keyword> KeywordAdded;
/// <summary>A keyword was removed.</summary>
public event Action<Keyword> KeywordRemoved;
/// <summary>A keyword changed.</summary>
public event Action<Keyword> KeywordChanged;
/// <summary>Graph settings changed.</summary>
public event Action SettingsChanged;
/// <summary>Every change, coarse and fine, in one stream.</summary>
public event Action<GraphChange> Changed;
/// <summary>
/// Suppress every event until the returned scope is disposed, then raise a single
/// <see cref="GraphChangeKind.Reloaded"/>. Used by load, clear and undo restore, where the editor
/// wants to diff the whole document once rather than react to a thousand individual edits.
/// </summary>
public IDisposable SuspendEvents()
{
_suspend++;
return new SuspendScope( this );
}
/// <summary>True while events are suppressed.</summary>
public bool EventsSuspended => _suspend > 0;
// ---------------------------------------------------------------- lookups ----
/// <summary>Look up a node by id. Null when it does not exist.</summary>
public PrismNode FindNode( NodeId id ) => id.IsValid && _nodeById.TryGetValue( id, out var node ) ? node : null;
/// <summary>Look up an edge by id. Null when it does not exist.</summary>
public Edge FindEdge( EdgeId id ) => id.IsValid && _edgeById.TryGetValue( id, out var edge ) ? edge : null;
/// <summary>Look up a port by reference. Null when either half does not resolve.</summary>
public Port FindPort( PortRef reference ) => FindNode( reference.Node )?.FindPort( reference.Port );
/// <summary>Look up an input port by reference.</summary>
public InputPort FindInput( PortRef reference ) => FindNode( reference.Node )?.FindInput( reference.Port );
/// <summary>Look up an output port by reference.</summary>
public OutputPort FindOutput( PortRef reference ) => FindNode( reference.Node )?.FindOutput( reference.Port );
/// <summary>The edge terminating on an input port, if any. The first one when the port allows several.</summary>
public bool TryGetIncomingEdge( NodeId node, PortId port, out Edge edge )
{
edge = null;
if ( !_incoming.TryGetValue( new PortRef( node, port ), out var list ) ) return false;
if ( list.Count == 0 ) return false;
edge = list[0];
return true;
}
/// <summary>Every edge terminating on an input port. More than one only for variadic inputs.</summary>
public IEnumerable<Edge> GetIncomingEdges( NodeId node, PortId port ) =>
_incoming.TryGetValue( new PortRef( node, port ), out var list ) ? list : Array.Empty<Edge>();
/// <summary>Every edge leaving an output port.</summary>
public IEnumerable<Edge> GetOutgoingEdges( NodeId node, PortId port ) =>
_outgoing.TryGetValue( new PortRef( node, port ), out var list ) ? list : Array.Empty<Edge>();
/// <summary>Every edge that touches a node at either end.</summary>
public IEnumerable<Edge> EdgesOf( NodeId node ) =>
_touching.TryGetValue( node, out var list ) ? list : Array.Empty<Edge>();
/// <summary>Every edge terminating on a node.</summary>
public IEnumerable<Edge> IncomingEdgesOf( NodeId node ) => EdgesOf( node ).Where( x => x.ToNode == node );
/// <summary>Every edge leaving a node.</summary>
public IEnumerable<Edge> OutgoingEdgesOf( NodeId node ) => EdgesOf( node ).Where( x => x.FromNode == node );
/// <summary>Look up a parameter by id.</summary>
public Parameter FindParameter( ParamId id ) => _parameters.FirstOrDefault( x => x.Id == id );
/// <summary>Look up a parameter by display name, case-insensitively.</summary>
public Parameter FindParameterByName( string name ) =>
_parameters.FirstOrDefault( x => string.Equals( x.Name, name, StringComparison.OrdinalIgnoreCase ) );
/// <summary>Look up a keyword by id.</summary>
public Keyword FindKeyword( ParamId id ) => _keywords.FirstOrDefault( x => x.Id == id );
/// <summary>Look up a keyword by name, case-insensitively.</summary>
public Keyword FindKeywordByName( string name ) =>
_keywords.FirstOrDefault( x => string.Equals( x.Name, name, StringComparison.OrdinalIgnoreCase ) );
/// <summary>Look up a group by id.</summary>
public GraphGroup FindGroup( string id ) =>
_groups.FirstOrDefault( x => string.Equals( x.Id, id, StringComparison.Ordinal ) );
/// <summary>Look up a sticky note by id.</summary>
public StickyNote FindNote( string id ) =>
_notes.FirstOrDefault( x => string.Equals( x.Id, id, StringComparison.Ordinal ) );
// ---------------------------------------------------------------- id minting ----
/// <summary>Mint a node id that is guaranteed free in this document.</summary>
public NodeId NewNodeId()
{
for ( int i = 0; i < 64; i++ )
{
var id = NodeId.New();
if ( !_nodeById.ContainsKey( id ) ) return id;
}
return new NodeId( Ids.NewShortId( 16 ) );
}
/// <summary>Mint an edge id that is guaranteed free in this document.</summary>
public EdgeId NewEdgeId()
{
for ( int i = 0; i < 64; i++ )
{
var id = EdgeId.New();
if ( !_edgeById.ContainsKey( id ) ) return id;
}
return new EdgeId( Ids.NewShortId( 16 ) );
}
/// <summary>Mint a parameter/keyword id that is guaranteed free in this document.</summary>
public ParamId NewParamId()
{
for ( int i = 0; i < 64; i++ )
{
var id = ParamId.New();
if ( FindParameter( id ) is null && FindKeyword( id ) is null ) return id;
}
return new ParamId( Ids.NewShortId( 16 ) );
}
// ---------------------------------------------------------------- node mutation ----
/// <summary>
/// Add a node, minting an id when it has none or its id is already taken. The node's existing id
/// is preserved whenever it is free, because ids must survive undo, save and copy-paste.
/// <para>Raw: does not push an undo entry. Call <c>GraphMutations.AddNode</c> from UI code.</para>
/// </summary>
public PrismNode AddNode( PrismNode node )
{
if ( node is null ) return null;
if ( !node.Id.IsValid || _nodeById.ContainsKey( node.Id ) )
{
node.Id = NewNodeId();
}
node.Graph = this;
// Publish the node's [InlineValue] properties onto the ports they are bound to. A node built in
// code has only the properties set; a node read from a document has both, because the serializer
// does this on load. Without it the same graph hashes differently before and after a save, and an
// inline editor bound to the port would show an empty slot next to a property holding the value.
NodeProperties.SyncInlineFromProperties( node );
_nodes.Add( node );
_nodeById[node.Id] = node;
Touch( new GraphChange( GraphChangeKind.NodeAdded ) { Node = node.Id } );
if ( !EventsSuspended ) PrismLog.Guard( "NodeAdded", () => NodeAdded?.Invoke( node ) );
return node;
}
/// <summary>
/// Rearrange the node list into the given order. Every current node must appear exactly once;
/// anything else is refused, because a partial reorder would lose nodes.
/// </summary>
public bool ReorderNodes( IReadOnlyList<PrismNode> order )
{
if ( order is null || order.Count != _nodes.Count ) return false;
var seen = new HashSet<NodeId>();
foreach ( var node in order )
{
if ( node is null || !_nodeById.ContainsKey( node.Id ) || !seen.Add( node.Id ) ) return false;
}
_nodes.Clear();
_nodes.AddRange( order );
return true;
}
/// <summary>The user-set card size of a node, or null when the card auto-sizes.</summary>
public Vector2? GetNodeSize( NodeId id ) =>
_nodeSizes.TryGetValue( id, out var size ) ? size : null;
/// <summary>Record a user-set card size. Null clears it and restores auto-sizing.</summary>
public void SetNodeSize( NodeId id, Vector2? size )
{
if ( size is { } value ) _nodeSizes[id] = value;
else _nodeSizes.Remove( id );
}
/// <summary>
/// The forward-compatibility bag of a registered node — keys a future schema wrote that this
/// build does not understand. Unknown node types keep theirs inside their raw JSON instead.
/// </summary>
public JsonObject GetNodeExtra( NodeId id ) => _nodeExtras.TryGetValue( id, out var extra ) ? extra : null;
/// <summary>Store a node's forward-compatibility bag so a save re-emits it.</summary>
public void SetNodeExtra( NodeId id, JsonObject extra )
{
if ( extra is { Count: > 0 } ) _nodeExtras[id] = extra.DeepClone() as JsonObject;
else _nodeExtras.Remove( id );
}
/// <summary>Add several nodes in one batch.</summary>
public void AddNodes( IEnumerable<PrismNode> nodes )
{
if ( nodes is null ) return;
foreach ( var node in nodes )
{
AddNode( node );
}
}
/// <summary>
/// Remove a node and every edge that touched it.
/// <para>Raw: does not push an undo entry. Call <c>GraphMutations.RemoveNode</c> from UI code.</para>
/// </summary>
public bool RemoveNode( NodeId id )
{
var node = FindNode( id );
return node is not null && RemoveNode( node );
}
/// <summary>Remove a node and every edge that touched it.</summary>
public bool RemoveNode( PrismNode node )
{
if ( node is null ) return false;
if ( !_nodeById.Remove( node.Id ) ) return false;
_nodes.Remove( node );
foreach ( var edge in EdgesOf( node.Id ).ToArray() )
{
RemoveEdge( edge.Id );
}
_touching.Remove( node.Id );
_nodeSizes.Remove( node.Id );
_nodeExtras.Remove( node.Id );
_broken.RemoveAll( x => x.Edge is not null && x.Edge.Touches( node.Id ) );
node.Graph = null;
Touch( new GraphChange( GraphChangeKind.NodeRemoved ) { Node = node.Id } );
if ( !EventsSuspended ) PrismLog.Guard( "NodeRemoved", () => NodeRemoved?.Invoke( node ) );
return true;
}
/// <inheritdoc/>
public void OnNodeChanged( PrismNode node, NodeChangeKind kind )
{
if ( node is null ) return;
if ( kind == NodeChangeKind.Ports ) BreakEdgesToMissingPorts( node );
Touch( new GraphChange( GraphChangeKind.NodeChanged ) { Node = node.Id, NodeChange = kind } );
if ( !EventsSuspended ) PrismLog.Guard( "NodeChanged", () => NodeChanged?.Invoke( node, kind ) );
}
// ---------------------------------------------------------------- edge mutation ----
/// <summary>
/// Add an edge exactly as given, without validation. Used by the reader and by undo restore, where
/// the document is the authority and unresolvable edges become ghosts rather than being rejected.
/// </summary>
public Edge AddEdge( Edge edge )
{
if ( edge is null || !edge.IsValid ) return null;
if ( !edge.Id.IsValid || _edgeById.ContainsKey( edge.Id ) )
{
edge = edge with { Id = NewEdgeId() };
}
_edges.Add( edge );
_edgeById[edge.Id] = edge;
Index( _incoming, edge.To, edge );
Index( _outgoing, edge.From, edge );
IndexTouching( edge.FromNode, edge );
if ( edge.ToNode != edge.FromNode ) IndexTouching( edge.ToNode, edge );
Touch( new GraphChange( GraphChangeKind.EdgeAdded ) { Edge = edge.Id } );
if ( !EventsSuspended ) PrismLog.Guard( "EdgeAdded", () => EdgeAdded?.Invoke( edge ) );
return edge;
}
/// <summary>
/// Connect two ports, replacing whatever already terminates on the target input unless the input
/// accepts several. Returns null when <see cref="CanConnect"/> would refuse.
/// <para>Raw: does not push an undo entry. Call <c>GraphMutations.Connect</c> from UI code.</para>
/// </summary>
public Edge Connect( PortRef from, PortRef to )
{
if ( !CanConnect( from, to, out _ ) ) return null;
var input = FindInput( to );
var allowsMultiple = input is not null && ( input.Flags & PortFlags.AllowMultiple ) != 0;
if ( !allowsMultiple )
{
foreach ( var existing in GetIncomingEdges( to.Node, to.Port ).ToArray() )
{
RemoveEdge( existing.Id );
}
}
return AddEdge( Edge.Between( NewEdgeId(), from, to ) );
}
/// <summary>Remove an edge by id.</summary>
public bool RemoveEdge( EdgeId id )
{
if ( !_edgeById.TryGetValue( id, out var edge ) ) return false;
_edgeById.Remove( id );
_edges.Remove( edge );
Unindex( _incoming, edge.To, edge );
Unindex( _outgoing, edge.From, edge );
UnindexTouching( edge.FromNode, edge );
UnindexTouching( edge.ToNode, edge );
Touch( new GraphChange( GraphChangeKind.EdgeRemoved ) { Edge = edge.Id } );
if ( !EventsSuspended ) PrismLog.Guard( "EdgeRemoved", () => EdgeRemoved?.Invoke( edge ) );
return true;
}
/// <summary>Replace an edge in place, keeping its id and position in the document.</summary>
public Edge ReplaceEdge( Edge edge )
{
if ( edge is null || !edge.Id.IsValid ) return null;
if ( !_edgeById.ContainsKey( edge.Id ) ) return null;
var index = _edges.FindIndex( x => x.Id == edge.Id );
RemoveEdge( edge.Id );
var added = AddEdge( edge );
if ( added is not null && index >= 0 && index < _edges.Count )
{
_edges.Remove( added );
_edges.Insert( index, added );
}
return added;
}
/// <summary>Remove every edge that terminates on or leaves a port.</summary>
public int DisconnectPort( PortRef port )
{
var removed = 0;
foreach ( var edge in GetIncomingEdges( port.Node, port.Port ).ToArray() )
{
if ( RemoveEdge( edge.Id ) ) removed++;
}
foreach ( var edge in GetOutgoingEdges( port.Node, port.Port ).ToArray() )
{
if ( RemoveEdge( edge.Id ) ) removed++;
}
return removed;
}
/// <summary>
/// Whether a connection is legal, and why not when it is not. Checks port existence and direction,
/// self-connection, type convertibility and cycles — the four ways a drop can be wrong.
/// </summary>
public bool CanConnect( PortRef from, PortRef to, out string reason )
{
reason = null;
if ( !from.IsValid || !to.IsValid )
{
reason = "Incomplete connection";
return false;
}
if ( from.Node == to.Node )
{
reason = "A node cannot connect to itself";
return false;
}
var output = FindOutput( from );
if ( output is null )
{
reason = $"'{from.Port}' is not an output of that node";
return false;
}
var input = FindInput( to );
if ( input is null )
{
reason = $"'{to.Port}' is not an input of that node";
return false;
}
var sourceType = output.EffectiveType;
var targetType = input.EffectiveType;
var passthrough = ( input.Flags & PortFlags.Passthrough ) != 0 ||
( output.Flags & PortFlags.Passthrough ) != 0;
if ( !passthrough && !sourceType.IsVoid && !targetType.IsVoid &&
!input.Def.IsGeneric && !output.Def.IsGeneric )
{
if ( !TypeRules.CanConvert( sourceType, targetType, out var kind ) || kind == ConversionKind.Illegal )
{
reason = TypeRules.Describe( sourceType, targetType, ConversionKind.Illegal );
return false;
}
}
if ( GraphQueries.WouldCreateCycle( this, from, to ) )
{
reason = "That connection would create a cycle";
return false;
}
return true;
}
/// <summary>Record a connection that could not be resolved, so it is drawn rather than lost.</summary>
public BrokenEdge AddBrokenEdge( Edge edge, BrokenEdgeReason reason, string detail = null )
{
if ( edge is null ) return null;
var broken = new BrokenEdge( edge, reason, detail );
_broken.Add( broken );
Touch( new GraphChange( GraphChangeKind.EdgeBroken ) { Edge = edge.Id } );
if ( !EventsSuspended ) PrismLog.Guard( "EdgeBroken", () => EdgeBroken?.Invoke( broken ) );
return broken;
}
/// <summary>Forget a ghost edge.</summary>
public bool RemoveBrokenEdge( EdgeId id )
{
var index = _broken.FindIndex( x => x.Id == id );
if ( index < 0 ) return false;
_broken.RemoveAt( index );
Touch( new GraphChange( GraphChangeKind.EdgeBroken ) { Edge = id } );
return true;
}
/// <summary>
/// Promote every ghost whose endpoints now exist back into a real edge. Called after a node's
/// ports are rebuilt and after a missing plugin is installed.
/// </summary>
public int RepairBrokenEdges()
{
var repaired = 0;
for ( int i = _broken.Count - 1; i >= 0; i-- )
{
var ghost = _broken[i];
var edge = ghost.Edge;
if ( edge is null )
{
_broken.RemoveAt( i );
continue;
}
if ( FindOutput( edge.From ) is null || FindInput( edge.To ) is null ) continue;
_broken.RemoveAt( i );
AddEdge( edge );
repaired++;
}
return repaired;
}
// ---------------------------------------------------------------- parameters ----
/// <summary>Add a parameter, minting an id when it has none or its id is taken.</summary>
public Parameter AddParameter( Parameter parameter, int index = -1 )
{
if ( parameter is null ) return null;
if ( !parameter.Id.IsValid || FindParameter( parameter.Id ) is not null ||
FindKeyword( parameter.Id ) is not null )
{
parameter.Id = NewParamId();
}
parameter.Name = UniqueParameterName( parameter.Name, parameter.Id );
if ( index < 0 || index > _parameters.Count ) _parameters.Add( parameter );
else _parameters.Insert( index, parameter );
Touch( new GraphChange( GraphChangeKind.ParameterAdded ) { Parameter = parameter.Id } );
if ( !EventsSuspended ) PrismLog.Guard( "ParameterAdded", () => ParameterAdded?.Invoke( parameter ) );
return parameter;
}
/// <summary>Remove a parameter. Nodes referencing it are left alone and report an unresolved reference.</summary>
public bool RemoveParameter( ParamId id )
{
var parameter = FindParameter( id );
if ( parameter is null ) return false;
_parameters.Remove( parameter );
Touch( new GraphChange( GraphChangeKind.ParameterRemoved ) { Parameter = id } );
if ( !EventsSuspended ) PrismLog.Guard( "ParameterRemoved", () => ParameterRemoved?.Invoke( parameter ) );
return true;
}
/// <summary>Announce that a parameter was edited in place.</summary>
public void NotifyParameterChanged( Parameter parameter )
{
if ( parameter is null ) return;
Touch( new GraphChange( GraphChangeKind.ParameterChanged ) { Parameter = parameter.Id } );
if ( !EventsSuspended ) PrismLog.Guard( "ParameterChanged", () => ParameterChanged?.Invoke( parameter ) );
}
/// <summary>Move a parameter to a new index in the blackboard order.</summary>
public bool ReorderParameter( ParamId id, int index )
{
var parameter = FindParameter( id );
if ( parameter is null ) return false;
var current = _parameters.IndexOf( parameter );
if ( current < 0 ) return false;
index = Math.Clamp( index, 0, _parameters.Count - 1 );
if ( index == current ) return false;
_parameters.RemoveAt( current );
_parameters.Insert( index, parameter );
Touch( new GraphChange( GraphChangeKind.ParameterChanged ) { Parameter = id } );
if ( !EventsSuspended ) PrismLog.Guard( "ParameterChanged", () => ParameterChanged?.Invoke( parameter ) );
return true;
}
/// <summary>A parameter name that no other parameter is using.</summary>
public string UniqueParameterName( string name, ParamId ignore = default )
{
if ( string.IsNullOrWhiteSpace( name ) ) name = "Parameter";
var candidate = name;
var counter = 2;
while ( _parameters.Any( x => x.Id != ignore &&
string.Equals( x.Name, candidate, StringComparison.OrdinalIgnoreCase ) ) )
{
candidate = $"{name} {counter++}";
}
return candidate;
}
// ---------------------------------------------------------------- keywords ----
/// <summary>Add a keyword, minting an id when it has none or its id is taken.</summary>
public Keyword AddKeyword( Keyword keyword, int index = -1 )
{
if ( keyword is null ) return null;
if ( !keyword.Id.IsValid || FindKeyword( keyword.Id ) is not null ||
FindParameter( keyword.Id ) is not null )
{
keyword.Id = NewParamId();
}
keyword.Name = UniqueKeywordName( keyword.Name, keyword.Kind, keyword.Id );
if ( index < 0 || index > _keywords.Count ) _keywords.Add( keyword );
else _keywords.Insert( index, keyword );
Touch( new GraphChange( GraphChangeKind.KeywordAdded ) { Parameter = keyword.Id } );
if ( !EventsSuspended ) PrismLog.Guard( "KeywordAdded", () => KeywordAdded?.Invoke( keyword ) );
return keyword;
}
/// <summary>Remove a keyword.</summary>
public bool RemoveKeyword( ParamId id )
{
var keyword = FindKeyword( id );
if ( keyword is null ) return false;
_keywords.Remove( keyword );
Touch( new GraphChange( GraphChangeKind.KeywordRemoved ) { Parameter = id } );
if ( !EventsSuspended ) PrismLog.Guard( "KeywordRemoved", () => KeywordRemoved?.Invoke( keyword ) );
return true;
}
/// <summary>
/// A keyword name no other keyword is using, once both are reduced to the combo name they actually
/// emit.
/// <para>
/// Uniqueness has to be judged on <see cref="Keyword.NormalizedName"/>, not the authored text:
/// <c>puddles</c>, <c>F_Puddles</c> and <c>F PUDDLES</c> are three different things in the blackboard
/// and the same <c>F_PUDDLES</c> in the block header. Two combos declared with one name is a
/// block-header parse failure, and that failure arrives with an empty program list and no diagnostic
/// at all — so it is caught here, at the only door keywords come through.
/// </para>
/// </summary>
public string UniqueKeywordName( string name, ComboKind kind = ComboKind.Feature, ParamId ignore = default )
{
if ( string.IsNullOrWhiteSpace( name ) ) name = Keyword.PrefixFor( kind ) + "KEYWORD";
var probe = new Keyword { Kind = kind };
bool Taken( string candidate )
{
probe.Name = candidate;
var normalized = probe.NormalizedName;
return _keywords.Any( x => x.Id != ignore &&
string.Equals( x.NormalizedName, normalized, StringComparison.OrdinalIgnoreCase ) );
}
var result = name;
var counter = 2;
while ( Taken( result ) && counter < 10000 )
{
result = $"{name}_{counter++}";
}
return result;
}
/// <summary>Announce that a keyword was edited in place.</summary>
public void NotifyKeywordChanged( Keyword keyword )
{
if ( keyword is null ) return;
Touch( new GraphChange( GraphChangeKind.KeywordChanged ) { Parameter = keyword.Id } );
if ( !EventsSuspended ) PrismLog.Guard( "KeywordChanged", () => KeywordChanged?.Invoke( keyword ) );
}
// ---------------------------------------------------------------- groups + notes ----
/// <summary>Add a group box.</summary>
public GraphGroup AddGroup( GraphGroup group )
{
if ( group is null ) return null;
if ( string.IsNullOrEmpty( group.Id ) || FindGroup( group.Id ) is not null )
{
group.Id = Ids.NewShortId();
}
_groups.Add( group );
Touch( new GraphChange( GraphChangeKind.GroupsChanged ) );
return group;
}
/// <summary>Remove a group box. Nodes inside it are untouched.</summary>
public bool RemoveGroup( string id )
{
var group = FindGroup( id );
if ( group is null ) return false;
_groups.Remove( group );
Touch( new GraphChange( GraphChangeKind.GroupsChanged ) );
return true;
}
/// <summary>Announce that a group was edited in place.</summary>
public void NotifyGroupsChanged() => Touch( new GraphChange( GraphChangeKind.GroupsChanged ) );
/// <summary>Add a sticky note.</summary>
public StickyNote AddNote( StickyNote note )
{
if ( note is null ) return null;
if ( string.IsNullOrEmpty( note.Id ) || FindNote( note.Id ) is not null )
{
note.Id = Ids.NewShortId();
}
_notes.Add( note );
Touch( new GraphChange( GraphChangeKind.NotesChanged ) );
return note;
}
/// <summary>Remove a sticky note.</summary>
public bool RemoveNote( string id )
{
var note = FindNote( id );
if ( note is null ) return false;
_notes.Remove( note );
Touch( new GraphChange( GraphChangeKind.NotesChanged ) );
return true;
}
/// <summary>Announce that a note was edited in place.</summary>
public void NotifyNotesChanged() => Touch( new GraphChange( GraphChangeKind.NotesChanged ) );
/// <summary>Announce that settings were edited in place.</summary>
public void NotifySettingsChanged()
{
Touch( new GraphChange( GraphChangeKind.SettingsChanged ) );
if ( !EventsSuspended ) PrismLog.Guard( "SettingsChanged", () => SettingsChanged?.Invoke() );
}
/// <summary>Announce that preview state was edited in place.</summary>
public void NotifyPreviewChanged() => Touch( new GraphChange( GraphChangeKind.PreviewChanged ) );
/// <summary>Announce that metadata was edited in place.</summary>
public void NotifyMetaChanged() => Touch( new GraphChange( GraphChangeKind.MetaChanged ) );
/// <summary>Announce that the pan or zoom changed. Cosmetic: never marks the document dirty.</summary>
public void NotifyViewChanged()
{
Version++;
if ( !EventsSuspended )
{
PrismLog.Guard( "Changed", () => Changed?.Invoke( new GraphChange( GraphChangeKind.ViewChanged ) ) );
}
}
// ---------------------------------------------------------------- hashes ----
/// <summary>
/// Hash of everything that changes generated code: node types, node properties, connectivity,
/// parameter and keyword declarations, and settings. A change here forces a recompile.
/// </summary>
public ulong StructureHash
{
get
{
EnsureHashes();
return _structureHash;
}
}
/// <summary>
/// Hash of everything that only changes uniform values: inline literals and parameter defaults.
/// A change here re-pushes render attributes without recompiling anything.
/// </summary>
public ulong ValueHash
{
get
{
EnsureHashes();
return _valueHash;
}
}
// ---------------------------------------------------------------- whole document ----
/// <summary>Remove everything. Raises a single <see cref="GraphChangeKind.Reloaded"/>.</summary>
public void Clear()
{
using ( SuspendEvents() )
{
foreach ( var node in _nodes )
{
node.Graph = null;
}
_nodes.Clear();
_nodeById.Clear();
_edges.Clear();
_edgeById.Clear();
_incoming.Clear();
_outgoing.Clear();
_touching.Clear();
_nodeSizes.Clear();
_nodeExtras.Clear();
_broken.Clear();
_parameters.Clear();
_keywords.Clear();
_groups.Clear();
_notes.Clear();
Touch( new GraphChange( GraphChangeKind.Reloaded ) );
}
}
/// <summary>
/// A deep copy of the whole document, produced by a serialization round-trip so it is guaranteed
/// to be exactly what a save/load would give. Ids are preserved.
/// </summary>
public PrismGraph Clone()
{
var json = PrismSerializer.Write( this );
var copy = PrismSerializer.Read( json );
if ( copy is not null ) copy.AssetPath = AssetPath;
return copy ?? new PrismGraph( IsSubgraph );
}
/// <inheritdoc/>
public override string ToString() =>
$"{Kind} '{Meta?.Title ?? DocumentId}' ({_nodes.Count} nodes, {_edges.Count} edges)";
// ---------------------------------------------------------------- internals ----
void EnsureHashes()
{
if ( _hashesValid ) return;
ulong structure = Fnv.Offset;
ulong value = Fnv.Offset;
structure = Fnv.Mix( structure, IsSubgraph ? 1UL : 0UL );
structure = Fnv.Mix( structure, (ulong)Settings.Domain );
structure = Fnv.Mix( structure, (ulong)Settings.ShadingModel );
structure = Fnv.Mix( structure, (ulong)Settings.BlendMode );
structure = Fnv.Mix( structure, (ulong)Settings.CullMode );
structure = Fnv.Mix( structure, (ulong)Settings.HlslDialect );
structure = Fnv.Mix( structure, Settings.Uv2 ? 1UL : 0UL );
structure = Fnv.Mix( structure, Settings.RenderBackfaces ? 1UL : 0UL );
structure = Fnv.Mix( structure, Settings.DebugSymbols ? 1UL : 0UL );
// The HEADER block carries the description verbatim, so editing it changes the generated text.
structure = Fnv.Mix( structure, Meta?.Description );
foreach ( var mode in Settings.Modes ?? new List<string>() )
{
structure = Fnv.Mix( structure, mode );
}
foreach ( var target in Settings.Targets ?? new List<string>() )
{
structure = Fnv.Mix( structure, target );
}
foreach ( var node in _nodes )
{
structure = Fnv.Mix( structure, node.Id.Value );
structure = Fnv.Mix( structure, NodeTypeId( node ) );
structure = Fnv.Mix( structure, (ulong)( node.Flags & ( NodeFlags.Disabled | NodeFlags.Preview ) ) );
structure = Fnv.Mix( structure, NodeProperties.StructuralHash( node ) );
value = Fnv.Mix( value, node.Id.Value );
value = Fnv.Mix( value, NodeProperties.ValueHash( node ) );
foreach ( var input in node.Inputs )
{
// The *effective* literal, which is the bound property when there is one. That is what
// the emitter compiles, so hashing anything else would let a value change slip past the
// "did this shader's constants move" check.
value = Fnv.Mix( value, input.Id.Value );
value = Fnv.Mix( value, Fnv.OfValue( NodeProperties.GetInline( node, input ) ) );
}
}
foreach ( var edge in _edges )
{
structure = Fnv.Mix( structure, edge.FromNode.Value );
structure = Fnv.Mix( structure, edge.FromPort.Value );
structure = Fnv.Mix( structure, edge.ToNode.Value );
structure = Fnv.Mix( structure, edge.ToPort.Value );
value = Fnv.Mix( value,
edge.Fill.HasValue ? (ulong)BitConverter.SingleToUInt32Bits( edge.Fill.Value ) : 0UL );
}
foreach ( var parameter in _parameters )
{
structure = Fnv.Mix( structure, parameter.Id.Value );
structure = Fnv.Mix( structure, parameter.Name );
structure = Fnv.Mix( structure, parameter.Type.ToString() );
structure = Fnv.Mix( structure, parameter.EffectiveAttributeName );
// Everything below here is written into the .shader as *text* — SrgbRead( … ),
// DefaultFile( "…" ), UiType/RangeN/UiGroup — so it belongs in the structural hash even
// though it reads like presentation. ShaderCompileService compares StructureHash to decide
// whether an in-flight compile has been superseded; without these, retyping a slider range
// or an sRGB flag lets a stale compile finish and be treated as current.
var declaration = (IGraphParameter)parameter;
structure = Fnv.Mix( structure, declaration.Srgb ? 1UL : 0UL );
structure = Fnv.Mix( structure, declaration.DefaultAsset );
var ui = parameter.Ui;
if ( ui is not null )
{
structure = Fnv.Mix( structure, (ulong)ui.Control );
structure = Fnv.Mix( structure, Fnv.OfValue( ui.Min ) );
structure = Fnv.Mix( structure, Fnv.OfValue( ui.Max ) );
structure = Fnv.Mix( structure, Fnv.OfValue( ui.Step ) );
structure = Fnv.Mix( structure, ui.Group );
structure = Fnv.Mix( structure, (ulong)ui.Order );
structure = Fnv.Mix( structure, ui.Tooltip );
foreach ( var option in ui.Options ?? new List<string>() )
{
structure = Fnv.Mix( structure, option );
}
}
value = Fnv.Mix( value, parameter.Id.Value );
value = Fnv.Mix( value, Fnv.OfValue( parameter.Default ) );
}
foreach ( var keyword in _keywords )
{
structure = Fnv.Mix( structure, keyword.Id.Value );
structure = Fnv.Mix( structure, keyword.NormalizedName );
structure = Fnv.Mix( structure, (ulong)keyword.Kind );
structure = Fnv.Mix( structure, (ulong)keyword.ValueCount );
// The labels are written into the FEATURES block as Feature( F_X, 0..N ( 0="…" ) ), so a
// relabel is a text change, not just a display change.
foreach ( var label in keyword.Values ?? new List<string>() )
{
structure = Fnv.Mix( structure, label );
}
value = Fnv.Mix( value, (ulong)keyword.SafeDefault );
}
_structureHash = structure;
_valueHash = value;
_hashesValid = true;
}
static string NodeTypeId( PrismNode node ) => node switch
{
UnknownNode unknown => unknown.TypeId,
null => string.Empty,
_ => node.Descriptor?.Id ?? node.GetType().FullName
};
void BreakEdgesToMissingPorts( PrismNode node )
{
foreach ( var edge in EdgesOf( node.Id ).ToArray() )
{
if ( edge.FromNode == node.Id && node.FindOutput( edge.FromPort ) is null )
{
RemoveEdge( edge.Id );
AddBrokenEdge( edge, BrokenEdgeReason.MissingFromPort,
$"'{edge.FromPort}' no longer exists on this node." );
continue;
}
if ( edge.ToNode == node.Id && node.FindInput( edge.ToPort ) is null )
{
RemoveEdge( edge.Id );
AddBrokenEdge( edge, BrokenEdgeReason.MissingToPort,
$"'{edge.ToPort}' no longer exists on this node." );
}
}
}
void Touch( GraphChange change )
{
Version++;
_hashesValid = false;
if ( change.Kind != GraphChangeKind.ViewChanged ) IsDirty = true;
if ( EventsSuspended )
{
_suspendedChange = true;
return;
}
PrismLog.Guard( "Changed", () => Changed?.Invoke( change ) );
}
void ReleaseSuspend()
{
if ( _suspend > 0 ) _suspend--;
if ( _suspend > 0 ) return;
if ( !_suspendedChange ) return;
_suspendedChange = false;
PrismLog.Guard( "Changed", () => Changed?.Invoke( new GraphChange( GraphChangeKind.Reloaded ) ) );
}
static void Index( Dictionary<PortRef, List<Edge>> map, PortRef key, Edge edge )
{
if ( !map.TryGetValue( key, out var list ) )
{
list = new List<Edge>( 1 );
map[key] = list;
}
list.Add( edge );
}
static void Unindex( Dictionary<PortRef, List<Edge>> map, PortRef key, Edge edge )
{
if ( !map.TryGetValue( key, out var list ) ) return;
list.Remove( edge );
if ( list.Count == 0 ) map.Remove( key );
}
void IndexTouching( NodeId node, Edge edge )
{
if ( !_touching.TryGetValue( node, out var list ) )
{
list = new List<Edge>( 2 );
_touching[node] = list;
}
list.Add( edge );
}
void UnindexTouching( NodeId node, Edge edge )
{
if ( !_touching.TryGetValue( node, out var list ) ) return;
list.Remove( edge );
if ( list.Count == 0 ) _touching.Remove( node );
}
sealed class SuspendScope : IDisposable
{
PrismGraph _graph;
public SuspendScope( PrismGraph graph )
{
_graph = graph;
}
public void Dispose()
{
var graph = _graph;
_graph = null;
graph?.ReleaseSuspend();
}
}
}
/// <summary>
/// The 64-bit FNV-1a mixer the document hashes are built from. Deliberately not a cryptographic hash
/// and deliberately not <see cref="object.GetHashCode"/>: this value has to be stable across process
/// runs so a compile cache keyed on it survives an editor restart.
/// </summary>
public static class Fnv
{
/// <summary>The FNV-1a 64-bit offset basis.</summary>
public const ulong Offset = 14695981039346656037UL;
/// <summary>The FNV-1a 64-bit prime.</summary>
public const ulong Prime = 1099511628211UL;
/// <summary>Mix eight bytes into a running hash.</summary>
public static ulong Mix( ulong hash, ulong value )
{
for ( int i = 0; i < 8; i++ )
{
hash ^= ( value >> ( i * 8 ) ) & 0xFF;
hash *= Prime;
}
return hash;
}
/// <summary>Mix a string into a running hash. Null and empty are distinguishable.</summary>
public static ulong Mix( ulong hash, string value )
{
if ( value is null ) return Mix( hash, 0x9E3779B97F4A7C15UL );
foreach ( var c in value )
{
hash ^= c & 0xFFu;
hash *= Prime;
hash ^= ( (uint)c >> 8 ) & 0xFFu;
hash *= Prime;
}
return hash;
}
/// <summary>Hash of a single string.</summary>
public static ulong Of( string value ) => Mix( Offset, value );
/// <summary>
/// Hash of a boxed value in the shapes the model stores: primitives, vectors, colours, enums and
/// strings. Anything else falls back to its invariant string form, which is stable enough for a
/// change detector and never throws.
/// </summary>
public static ulong OfValue( object value )
{
var hash = Offset;
switch ( value )
{
case null:
return Mix( hash, 0xF00DUL );
case bool b:
return Mix( hash, b ? 1UL : 0UL );
case int i:
return Mix( hash, unchecked((ulong)i) );
case long l:
return Mix( hash, unchecked((ulong)l) );
case float f:
return Mix( hash, BitConverter.SingleToUInt32Bits( f ) );
case double d:
return Mix( hash, BitConverter.DoubleToUInt64Bits( d ) );
case string s:
return Mix( hash, s );
case Vector2 v2:
hash = Mix( hash, BitConverter.SingleToUInt32Bits( v2.x ) );
return Mix( hash, BitConverter.SingleToUInt32Bits( v2.y ) );
case Vector3 v3:
hash = Mix( hash, BitConverter.SingleToUInt32Bits( v3.x ) );
hash = Mix( hash, BitConverter.SingleToUInt32Bits( v3.y ) );
return Mix( hash, BitConverter.SingleToUInt32Bits( v3.z ) );
case Vector4 v4:
hash = Mix( hash, BitConverter.SingleToUInt32Bits( v4.x ) );
hash = Mix( hash, BitConverter.SingleToUInt32Bits( v4.y ) );
hash = Mix( hash, BitConverter.SingleToUInt32Bits( v4.z ) );
return Mix( hash, BitConverter.SingleToUInt32Bits( v4.w ) );
case Color c:
hash = Mix( hash, BitConverter.SingleToUInt32Bits( c.r ) );
hash = Mix( hash, BitConverter.SingleToUInt32Bits( c.g ) );
hash = Mix( hash, BitConverter.SingleToUInt32Bits( c.b ) );
return Mix( hash, BitConverter.SingleToUInt32Bits( c.a ) );
case Enum e:
return Mix( hash, unchecked((ulong)Convert.ToInt64( e )) );
case JsonNode json:
return Mix( hash, json.ToJsonString() );
default:
return Mix( hash, PrismLog.Guard( "hash value", () => value.ToString(), string.Empty ) );
}
}
}