From ea0108fde6dbb76838ddfd1388808e5f573da081 Mon Sep 17 00:00:00 2001 From: Brian Whitenack Date: Fri, 12 Sep 2025 09:09:24 -0500 Subject: [PATCH 1/8] Type fixes --- MathSample/FormMathSample.cs | 3 - MathSample/MathContext.cs | 18 +++ MathSample/MathSample.csproj | 7 +- MathSample/Properties/Resources.Designer.cs | 44 +++--- MathSample/Properties/Settings.Designer.cs | 22 ++- MathSample/app.config | 3 + NodeEditor/NodeEditor.csproj | 5 +- NodeEditor/NodeVisual.cs | 148 ++++++++++++++---- NodeEditor/NodesControl.cs | 17 +- NodeEditor/Resources.Designer.cs | 2 +- SampleCommon/Properties/Resources.Designer.cs | 2 +- SampleCommon/SampleCommon.csproj | 5 +- 12 files changed, 193 insertions(+), 83 deletions(-) create mode 100644 MathSample/app.config diff --git a/MathSample/FormMathSample.cs b/MathSample/FormMathSample.cs index 950ab10..12c4219 100644 --- a/MathSample/FormMathSample.cs +++ b/MathSample/FormMathSample.cs @@ -25,9 +25,6 @@ private void FormMathSample_Load(object sender, EventArgs e) //Context assignment controlNodeEditor.nodesControl.Context = context; controlNodeEditor.nodesControl.OnNodeContextSelected += NodesControlOnOnNodeContextSelected; - - //Loading sample from file - controlNodeEditor.nodesControl.Deserialize(File.ReadAllBytes("default.nds")); } private void NodesControlOnOnNodeContextSelected(object o) diff --git a/MathSample/MathContext.cs b/MathSample/MathContext.cs index 93ff320..84fad3b 100644 --- a/MathSample/MathContext.cs +++ b/MathSample/MathContext.cs @@ -14,6 +14,24 @@ public class MathContext : INodesContext public NodeVisual CurrentProcessingNode { get; set; } public event Action FeedbackInfo; + [Node("String Value", "Input", "Basic", "Allows to output a simple string value.",false)] + public void StringValue(string inValue, out string outValue) + { + outValue = inValue; + } + + [Node("String List Value", "Input", "Basic", "Allows to output a simple string list value.", false)] + public void StringListValue(string[] inValue, out string[] outValue) + { + outValue = inValue; + } + + [Node("Foreach", "Operators", "Basic", "Allows to output a simple string list value.", true)] + public void Foreach(IEnumerable inValue) + { + ; + } + [Node("Value", "Input", "Basic", "Allows to output a simple value.",false)] public void InputValue(float inValue, out float outValue) { diff --git a/MathSample/MathSample.csproj b/MathSample/MathSample.csproj index 865d8c0..c445a03 100644 --- a/MathSample/MathSample.csproj +++ b/MathSample/MathSample.csproj @@ -9,8 +9,9 @@ Properties MathSample MathSample - v4.0 + v4.8 512 + AnyCPU @@ -21,6 +22,7 @@ DEBUG;TRACE prompt 4 + false AnyCPU @@ -30,6 +32,7 @@ TRACE prompt 4 + false @@ -64,7 +67,9 @@ True Resources.resx + True + PreserveNewest diff --git a/MathSample/Properties/Resources.Designer.cs b/MathSample/Properties/Resources.Designer.cs index 19d0c1f..8f04467 100644 --- a/MathSample/Properties/Resources.Designer.cs +++ b/MathSample/Properties/Resources.Designer.cs @@ -8,10 +8,10 @@ // //------------------------------------------------------------------------------ -namespace MathSample.Properties -{ - - +namespace MathSample.Properties { + using System; + + /// /// A strongly-typed resource class, for looking up localized strings, etc. /// @@ -19,51 +19,43 @@ namespace MathSample.Properties // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - internal class Resources - { - + internal class Resources { + private static global::System.Resources.ResourceManager resourceMan; - + private static global::System.Globalization.CultureInfo resourceCulture; - + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] - internal Resources() - { + internal Resources() { } - + /// /// Returns the cached ResourceManager instance used by this class. /// [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Resources.ResourceManager ResourceManager - { - get - { - if ((resourceMan == null)) - { + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("MathSample.Properties.Resources", typeof(Resources).Assembly); resourceMan = temp; } return resourceMan; } } - + /// /// Overrides the current thread's CurrentUICulture property for all /// resource lookups using this strongly typed resource class. /// [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] - internal static global::System.Globalization.CultureInfo Culture - { - get - { + internal static global::System.Globalization.CultureInfo Culture { + get { return resourceCulture; } - set - { + set { resourceCulture = value; } } diff --git a/MathSample/Properties/Settings.Designer.cs b/MathSample/Properties/Settings.Designer.cs index 3a868ed..f1f771d 100644 --- a/MathSample/Properties/Settings.Designer.cs +++ b/MathSample/Properties/Settings.Designer.cs @@ -8,21 +8,17 @@ // //------------------------------------------------------------------------------ -namespace MathSample.Properties -{ - - +namespace MathSample.Properties { + + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "11.0.0.0")] - internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase - { - + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.14.0.0")] + internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { + private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); - - public static Settings Default - { - get - { + + public static Settings Default { + get { return defaultInstance; } } diff --git a/MathSample/app.config b/MathSample/app.config new file mode 100644 index 0000000..3e0e37c --- /dev/null +++ b/MathSample/app.config @@ -0,0 +1,3 @@ + + + diff --git a/NodeEditor/NodeEditor.csproj b/NodeEditor/NodeEditor.csproj index b979688..97e866f 100644 --- a/NodeEditor/NodeEditor.csproj +++ b/NodeEditor/NodeEditor.csproj @@ -9,8 +9,9 @@ Properties NodeEditor NodeEditor - v4.0 + v4.8 512 + true @@ -20,6 +21,7 @@ DEBUG;TRACE prompt 4 + false pdbonly @@ -28,6 +30,7 @@ TRACE prompt 4 + false diff --git a/NodeEditor/NodeVisual.cs b/NodeEditor/NodeVisual.cs index e6e9825..4c16d8e 100644 --- a/NodeEditor/NodeVisual.cs +++ b/NodeEditor/NodeVisual.cs @@ -172,56 +172,136 @@ internal void DiscardCache() /// public object GetNodeContext() { - const string stringTypeName = "System.String"; - if (nodeContext == null) - { - dynamic context = new DynamicNodeContext(); + { + DynamicNodeContext context = new DynamicNodeContext(); - foreach (var input in GetInputs()) + foreach (ParameterInfo input in GetInputs()) { - var contextName = input.Name.Replace(" ", ""); - if (input.ParameterType.FullName.Replace("&", "") == stringTypeName) + string contextName = input.Name.Replace(" ", ""); + Type paramType = input.ParameterType; + + // Handle ref/out parameters by getting the underlying type + if (paramType.IsByRef) { - context[contextName] = string.Empty; - } - else - { - try - { - context[contextName] = Activator.CreateInstance(AppDomain.CurrentDomain, input.ParameterType.Assembly.GetName().Name, - input.ParameterType.FullName.Replace("&", "").Replace(" ", "")).Unwrap(); - } - catch (MissingMethodException ex) //For case when type does not have default constructor - { - context[contextName] = null; - } + paramType = paramType.GetElementType(); } + + context[contextName] = CreateDefaultInstance(paramType); } - foreach (var output in GetOutputs()) + foreach (ParameterInfo output in GetOutputs()) { var contextName = output.Name.Replace(" ", ""); - if (output.ParameterType.FullName.Replace("&", "") == stringTypeName) + Type paramType = output.ParameterType; + + // Handle ref/out parameters by getting the underlying type + if (paramType.IsByRef) { - context[contextName] = string.Empty; + paramType = paramType.GetElementType(); } - else + + context[contextName] = CreateDefaultInstance(paramType); + } + + nodeContext = context; + } + return nodeContext; + } + + /// + /// Creates a default instance of the specified type, handling special cases like arrays, strings, and value types. + /// + private object CreateDefaultInstance(Type type) + { + // Handle string type + if (type == typeof(string)) + { + return string.Empty; + } + + // Handle array types + if (type.IsArray) + { + // Create an empty array of the appropriate type + return Array.CreateInstance(type.GetElementType(), 0); + } + + // Handle generic collection types (List, IList, IEnumerable, etc.) + if (type.IsGenericType) + { + Type genericTypeDef = type.GetGenericTypeDefinition(); + Type[] genericArgs = type.GetGenericArguments(); + + // Handle IEnumerable, IList, ICollection interfaces + if (genericTypeDef == typeof(IEnumerable<>) || + genericTypeDef == typeof(IList<>) || + genericTypeDef == typeof(ICollection<>)) + { + // Create a List for interface types + Type listType = typeof(List<>).MakeGenericType(genericArgs); + return Activator.CreateInstance(listType); + } + + // Handle Dictionary and IDictionary + if (genericTypeDef == typeof(IDictionary<,>) || + genericTypeDef == typeof(Dictionary<,>)) + { + Type dictType = typeof(Dictionary<,>).MakeGenericType(genericArgs); + return Activator.CreateInstance(dictType); + } + + // Handle HashSet and ISet + if (genericTypeDef == typeof(ISet<>) || + genericTypeDef == typeof(HashSet<>)) + { + Type hashSetType = typeof(HashSet<>).MakeGenericType(genericArgs); + return Activator.CreateInstance(hashSetType); + } + + // Try to create instance for other generic types + try + { + return Activator.CreateInstance(type); + } + catch + { + // If it's a generic interface or abstract class, try to create a List as fallback + if (type.IsInterface || type.IsAbstract) { - try - { - context[contextName] = Activator.CreateInstance(AppDomain.CurrentDomain, output.ParameterType.Assembly.GetName().Name, - output.ParameterType.FullName.Replace("&", "").Replace(" ", "")).Unwrap(); - } - catch(MissingMethodException ex) //For case when type does not have default constructor + if (genericArgs.Length == 1) { - context[contextName] = null; + Type listType = typeof(List<>).MakeGenericType(genericArgs); + return Activator.CreateInstance(listType); } } + return null; } - - nodeContext = context; } - return nodeContext; + + // Handle non-generic collection interfaces + if (type == typeof(System.Collections.IEnumerable) || + type == typeof(System.Collections.IList) || + type == typeof(System.Collections.ICollection)) + { + return new System.Collections.ArrayList(); + } + + // Handle value types (int, float, structs, etc.) + if (type.IsValueType) + { + return Activator.CreateInstance(type); + } + + // Handle reference types with parameterless constructor + try + { + return Activator.CreateInstance(type); + } + catch (Exception) + { + // Return null for types without parameterless constructor + return null; + } } internal ParameterInfo[] GetInputs() diff --git a/NodeEditor/NodesControl.cs b/NodeEditor/NodesControl.cs index 1676075..da340dc 100644 --- a/NodeEditor/NodesControl.cs +++ b/NodeEditor/NodesControl.cs @@ -346,8 +346,21 @@ private bool IsConnectable(SocketVisual a, SocketVisual b) var otype = Type.GetType(output.Type.FullName.Replace("&", ""), AssemblyResolver, TypeResolver); var itype = Type.GetType(input.Type.FullName.Replace("&", ""), AssemblyResolver, TypeResolver); if (otype == null || itype == null) return false; - var allow = otype == itype || otype.IsSubclassOf(itype); - return allow; + + // Check for exact match + if (otype == itype) return true; + + // Check for inheritance + if (otype.IsSubclassOf(itype)) return true; + + // Check for interface implementation + if (itype.IsInterface && itype.IsAssignableFrom(otype)) return true; + + // Special case: Check if output type can be assigned to input type + // This handles cases like string[] to IEnumerable + if (itype.IsAssignableFrom(otype)) return true; + + return false; } private Type TypeResolver(Assembly assembly, string name, bool inh) diff --git a/NodeEditor/Resources.Designer.cs b/NodeEditor/Resources.Designer.cs index 8857a14..1bfb57e 100644 --- a/NodeEditor/Resources.Designer.cs +++ b/NodeEditor/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace NodeEditor { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Resources { diff --git a/SampleCommon/Properties/Resources.Designer.cs b/SampleCommon/Properties/Resources.Designer.cs index 5f436fc..0394864 100644 --- a/SampleCommon/Properties/Resources.Designer.cs +++ b/SampleCommon/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace SampleCommon.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] internal class Resources { diff --git a/SampleCommon/SampleCommon.csproj b/SampleCommon/SampleCommon.csproj index e5bffba..c1d3c33 100644 --- a/SampleCommon/SampleCommon.csproj +++ b/SampleCommon/SampleCommon.csproj @@ -9,8 +9,9 @@ Properties SampleCommon SampleCommon - v4.0 + v4.8 512 + true @@ -20,6 +21,7 @@ DEBUG;TRACE prompt 4 + false pdbonly @@ -28,6 +30,7 @@ TRACE prompt 4 + false From d94f0134fbb81fce852bf7b557c074742d9e2156 Mon Sep 17 00:00:00 2001 From: Brian Whitenack Date: Fri, 12 Sep 2025 10:34:24 -0500 Subject: [PATCH 2/8] feat: ControlFlow --- .claude/settings.local.json | 15 + MathSample/FormMathSample.Designer.cs | 51 +- MathSample/FormMathSample.cs | 68 +++ MathSample/FormMathSample.resx | 34 ++ MathSample/MathContext.cs | 72 ++- NodeEditor/DynamicNodeContext.cs | 114 +++- NodeEditor/DynamicNodeContextConverter.cs | 11 +- NodeEditor/FlowControls/ForeachFlowControl.cs | 73 +++ NodeEditor/IFlowControlNode.cs | 23 + NodeEditor/LoopFeedbackAttribute.cs | 15 + NodeEditor/NodeAttribute.cs | 9 +- NodeEditor/NodeEditor.csproj | 7 + NodeEditor/NodeVisual.cs | 13 +- NodeEditor/NodesControl.cs | 496 ++++++++++++++---- NodeEditor/Serialization/NodeGraphModel.cs | 144 +++++ NodeEditor/packages.config | 4 + 16 files changed, 999 insertions(+), 150 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 NodeEditor/FlowControls/ForeachFlowControl.cs create mode 100644 NodeEditor/IFlowControlNode.cs create mode 100644 NodeEditor/LoopFeedbackAttribute.cs create mode 100644 NodeEditor/Serialization/NodeGraphModel.cs create mode 100644 NodeEditor/packages.config diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c3afe87 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(dir:*)", + "Bash(findstr:*)", + "Bash(dotnet add:*)", + "Bash(nuget restore:*)", + "Bash(dotnet restore:*)", + "Bash(dotnet build)", + "Bash(msbuild:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/MathSample/FormMathSample.Designer.cs b/MathSample/FormMathSample.Designer.cs index d0a5446..efaab50 100644 --- a/MathSample/FormMathSample.Designer.cs +++ b/MathSample/FormMathSample.Designer.cs @@ -28,15 +28,53 @@ protected override void Dispose(bool disposing) /// private void InitializeComponent() { + System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(FormMathSample)); + this.toolStrip1 = new System.Windows.Forms.ToolStrip(); + this.btnSave = new System.Windows.Forms.ToolStripButton(); + this.btnLoad = new System.Windows.Forms.ToolStripButton(); this.controlNodeEditor = new SampleCommon.ControlNodeEditor(); + this.toolStrip1.SuspendLayout(); this.SuspendLayout(); // + // toolStrip1 + // + this.toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.btnSave, + this.btnLoad}); + this.toolStrip1.Location = new System.Drawing.Point(0, 0); + this.toolStrip1.Name = "toolStrip1"; + this.toolStrip1.Size = new System.Drawing.Size(957, 25); + this.toolStrip1.TabIndex = 1; + this.toolStrip1.Text = "toolStrip1"; + // + // btnSave + // + this.btnSave.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnSave.Image = ((System.Drawing.Image)(resources.GetObject("btnSave.Image"))); + this.btnSave.ImageTransparentColor = System.Drawing.Color.Magenta; + this.btnSave.Name = "btnSave"; + this.btnSave.Size = new System.Drawing.Size(35, 22); + this.btnSave.Text = "Save"; + this.btnSave.Click += new System.EventHandler(this.btnSave_Click); + // + // btnLoad + // + this.btnLoad.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnLoad.Image = ((System.Drawing.Image)(resources.GetObject("btnLoad.Image"))); + this.btnLoad.ImageTransparentColor = System.Drawing.Color.Magenta; + this.btnLoad.Name = "btnLoad"; + this.btnLoad.Size = new System.Drawing.Size(37, 22); + this.btnLoad.Text = "Load"; + this.btnLoad.Click += new System.EventHandler(this.btnLoad_Click); + // // controlNodeEditor // - this.controlNodeEditor.Dock = System.Windows.Forms.DockStyle.Fill; - this.controlNodeEditor.Location = new System.Drawing.Point(0, 0); + this.controlNodeEditor.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.controlNodeEditor.Location = new System.Drawing.Point(0, 28); this.controlNodeEditor.Name = "controlNodeEditor"; - this.controlNodeEditor.Size = new System.Drawing.Size(957, 510); + this.controlNodeEditor.Size = new System.Drawing.Size(957, 482); this.controlNodeEditor.TabIndex = 0; // // FormMathSample @@ -44,17 +82,24 @@ private void InitializeComponent() this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.ClientSize = new System.Drawing.Size(957, 510); + this.Controls.Add(this.toolStrip1); this.Controls.Add(this.controlNodeEditor); this.Name = "FormMathSample"; this.Text = "NodeEditor WinForms - Math Sample"; this.Load += new System.EventHandler(this.FormMathSample_Load); + this.toolStrip1.ResumeLayout(false); + this.toolStrip1.PerformLayout(); this.ResumeLayout(false); + this.PerformLayout(); } #endregion private SampleCommon.ControlNodeEditor controlNodeEditor; + private System.Windows.Forms.ToolStrip toolStrip1; + private System.Windows.Forms.ToolStripButton btnSave; + private System.Windows.Forms.ToolStripButton btnLoad; } } diff --git a/MathSample/FormMathSample.cs b/MathSample/FormMathSample.cs index 12c4219..37daa10 100644 --- a/MathSample/FormMathSample.cs +++ b/MathSample/FormMathSample.cs @@ -31,5 +31,73 @@ private void NodesControlOnOnNodeContextSelected(object o) { controlNodeEditor.propertyGrid.SelectedObject = o; } + + private void btnSave_Click(object sender, EventArgs e) + { + SaveFileDialog saveFileDialog = new SaveFileDialog(); + saveFileDialog.Filter = "JSON Node Graph Files (*.json)|*.json|Binary Node Graph Files (*.nod)|*.nod|All Files (*.*)|*.*"; + saveFileDialog.DefaultExt = "json"; + saveFileDialog.Title = "Save Node Graph"; + + if (saveFileDialog.ShowDialog() == DialogResult.OK) + { + try + { + if (saveFileDialog.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + // Save as JSON + string jsonData = controlNodeEditor.nodesControl.SerializeToJson(); + File.WriteAllText(saveFileDialog.FileName, jsonData); + } + else + { + // Save as binary (legacy) + byte[] graphData = controlNodeEditor.nodesControl.Serialize(); + File.WriteAllBytes(saveFileDialog.FileName, graphData); + } + MessageBox.Show("Node graph saved successfully!", "Save Complete", + MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + MessageBox.Show($"Error saving node graph: {ex.Message}", "Save Error", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } + + private void btnLoad_Click(object sender, EventArgs e) + { + OpenFileDialog openFileDialog = new OpenFileDialog(); + openFileDialog.Filter = "JSON Node Graph Files (*.json)|*.json|Binary Node Graph Files (*.nod)|*.nod|All Files (*.*)|*.*"; + openFileDialog.DefaultExt = "json"; + openFileDialog.Title = "Load Node Graph"; + + if (openFileDialog.ShowDialog() == DialogResult.OK) + { + try + { + if (openFileDialog.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + // Load from JSON + string jsonData = File.ReadAllText(openFileDialog.FileName); + controlNodeEditor.nodesControl.DeserializeFromJson(jsonData); + } + else + { + // Load from binary (legacy) + byte[] graphData = File.ReadAllBytes(openFileDialog.FileName); + controlNodeEditor.nodesControl.Deserialize(graphData); + } + MessageBox.Show("Node graph loaded successfully!", "Load Complete", + MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + MessageBox.Show($"Error loading node graph: {ex.Message}", "Load Error", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + } } } diff --git a/MathSample/FormMathSample.resx b/MathSample/FormMathSample.resx index 1af7de1..9f0b17f 100644 --- a/MathSample/FormMathSample.resx +++ b/MathSample/FormMathSample.resx @@ -117,4 +117,38 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + 17, 17 + + + + + iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8 + YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIFSURBVDhPpZLtS1NhGMbPPxJmmlYSgqHiKzGU1EDxg4iK + YKyG2WBogqMYJQOtCEVRFBGdTBCJfRnkS4VaaWNT5sqx1BUxRXxDHYxAJLvkusEeBaPAB+5z4Jzn+t3X + /aLhnEfjo8m+dCoa+7/C3O2Hqe0zDC+8KG+cRZHZhdzaaWTVTCLDMIY0vfM04Nfh77/G/sEhwpEDbO3t + I7TxE8urEVy99fT/AL5gWDLrTB/hnF4XsW0khCu5ln8DmJliT2AXrcNBsU1gj/MH4nMeKwBrPktM28xM + cX79DFKrHHD5d9D26hvicx4pABt2lpg10zYzU0zr7+e3xXGcrkEB2O2TNec9nJFwB3alZn5jZorfeDZh + 6Q3g8s06BeCoKF4MRURoH1+BY2oNCbeb0TIclIYxOhzf8frTOuo7FxCbbVIAzpni0iceEc8vhzEwGkJD + lx83ymxifejdKjRNk/8PWnyIyTQqAJek0jqHwfEVscu31baIu8+90sTE4nY025dQ2/5FIPpnXlzKuK8A + HBUzHot52djqQ6HZhfR7IwK4mKpHtvEDMqvfCiQ6zaAAXM8x94aIWTNrLLG4kVUzgaTSPlzLtyJOZxbb + 1wtfyg4Q+AfA3aZlButjSfxGcUJBk4g5tuP3haQKRKXcUQDOmbvNTpPOJeFFjordZmbWTNvMTHFUcpUC + nOccAdABIDXXE1nzAAAAAElFTkSuQmCC + + + + + iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8 + YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIFSURBVDhPpZLtS1NhGMbPPxJmmlYSgqHiKzGU1EDxg4iK + YKyG2WBogqMYJQOtCEVRFBGdTBCJfRnkS4VaaWNT5sqx1BUxRXxDHYxAJLvkusEeBaPAB+5z4Jzn+t3X + /aLhnEfjo8m+dCoa+7/C3O2Hqe0zDC+8KG+cRZHZhdzaaWTVTCLDMIY0vfM04Nfh77/G/sEhwpEDbO3t + I7TxE8urEVy99fT/AL5gWDLrTB/hnF4XsW0khCu5ln8DmJliT2AXrcNBsU1gj/MH4nMeKwBrPktM28xM + cX79DFKrHHD5d9D26hvicx4pABt2lpg10zYzU0zr7+e3xXGcrkEB2O2TNec9nJFwB3alZn5jZorfeDZh + 6Q3g8s06BeCoKF4MRURoH1+BY2oNCbeb0TIclIYxOhzf8frTOuo7FxCbbVIAzpni0iceEc8vhzEwGkJD + lx83ymxifejdKjRNk/8PWnyIyTQqAJek0jqHwfEVscu31baIu8+90sTE4nY025dQ2/5FIPpnXlzKuK8A + HBUzHot52djqQ6HZhfR7IwK4mKpHtvEDMqvfCiQ6zaAAXM8x94aIWTNrLLG4kVUzgaTSPlzLtyJOZxbb + 1wtfyg4Q+AfA3aZlButjSfxGcUJBk4g5tuP3haQKRKXcUQDOmbvNTpPOJeFFjordZmbWTNvMTHFUcpUC + nOccAdABIDXXE1nzAAAAAElFTkSuQmCC + + \ No newline at end of file diff --git a/MathSample/MathContext.cs b/MathSample/MathContext.cs index 84fad3b..2af0b8e 100644 --- a/MathSample/MathContext.cs +++ b/MathSample/MathContext.cs @@ -3,7 +3,9 @@ using System.Linq; using System.Text; using System.Windows.Forms; + using NodeEditor; +using NodeEditor.FlowControls; namespace MathSample { @@ -14,7 +16,7 @@ public class MathContext : INodesContext public NodeVisual CurrentProcessingNode { get; set; } public event Action FeedbackInfo; - [Node("String Value", "Input", "Basic", "Allows to output a simple string value.",false)] + [Node("String Value", "Input", "Basic", "Allows to output a simple string value.", false)] public void StringValue(string inValue, out string outValue) { outValue = inValue; @@ -26,52 +28,88 @@ public void StringListValue(string[] inValue, out string[] outValue) outValue = inValue; } - [Node("Foreach", "Operators", "Basic", "Allows to output a simple string list value.", true)] - public void Foreach(IEnumerable inValue) + [Node("For Each", "Loops", "Functional", "Transforms each item in a collection and returns the results.", true, + flowControlHandler: typeof(ForEachFlowControl), Width = 250)] + public void ForEach(IEnumerable inputCollection, [LoopFeedback] object loopResult, out object currentItemInLoop, out ExecutionPath forEachItemLoop, out IEnumerable forEachResult) { - ; + // Initialize outputs - actual values will be set by the flow control handler + currentItemInLoop = null; + forEachItemLoop = new ExecutionPath(); + forEachResult = null; } - [Node("Value", "Input", "Basic", "Allows to output a simple value.",false)] + [Node("Value", "Input", "Basic", "Allows to output a simple value.", false)] public void InputValue(float inValue, out float outValue) { outValue = inValue; } - [Node("Add","Operators","Basic","Adds two input values.",false)] + [Node("Add", "Operators", "Basic", "Adds two input values.", false)] public void Add(float a, float b, out float result) { result = a + b; } - [Node("Substract", "Operators", "Basic", "Substracts two input values.", false)] - public void Substract(float a, float b, out float result) + [Node("Subtract", "Operators", "Basic", "Substracts two input values.", true)] + public void Subtract(float a, float b, out float result) { result = a - b; } - [Node("Multiply", "Operators", "Basic", "Multiplies two input values.", false)] - public void Multiplty(float a, float b, out float result) + [Node("Multiply", "Operators", "Basic", "Multiplies two input values.", true)] + public void Multiply(float a, float b, out float result) { result = a * b; } - [Node("Divide", "Operators", "Basic", "Divides two input values.", false)] - public void Divid(float a, float b, out float result) + [Node("Divide", "Operators", "Basic", "Divides two input values.", true)] + public void Divide(float a, float b, out float result) { result = a / b; } - [Node("Show Value","Helper","Basic","Shows input value in the message box.")] - public void ShowMessageBox(float x) + [Node("Show Value", "Helper", "Basic", "Shows input value in the message box.")] + public void ShowMessageBox(object x) { - MessageBox.Show(x.ToString(), "Show Value", MessageBoxButtons.OK, MessageBoxIcon.Information); + string valueToShow; + if (x == null) + { + valueToShow = "null"; + } + else if(x is IEnumerable va) + { + valueToShow = string.Join(", ", va.Select(item => item?.ToString() ?? "null")); + } + else + { + valueToShow = x.ToString(); + } + + MessageBox.Show(valueToShow, "Show Value", MessageBoxButtons.OK, MessageBoxIcon.Information); } - [Node("Starter","Helper","Basic","Starts execution",true,true)] + [Node("To Upper", "Operators", "String", "Converts a string to uppercase.", true)] + public void ToUpper(string input, out string output) + { + output = input?.ToUpper(); + } + + [Node("Concatenate", "Operators", "String", "Concatenates two strings.", true)] + public void Concatenate(string a, string b, out string result) + { + result = a + b; + } + + [Node("To String", "Operators", "String", "Converts to a string.", true)] + public void ToStringNode(object a, out string result) + { + result = a?.ToString(); + } + + [Node("Starter", "Helper", "Basic", "Starts execution", true, true)] public void Starter() { - + } } } diff --git a/NodeEditor/DynamicNodeContext.cs b/NodeEditor/DynamicNodeContext.cs index a244c4e..cd15c13 100644 --- a/NodeEditor/DynamicNodeContext.cs +++ b/NodeEditor/DynamicNodeContext.cs @@ -22,15 +22,19 @@ using System.Dynamic; using System.IO; using System.Linq; +using System.Net.Sockets; using System.Runtime.Serialization.Formatters.Binary; using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + namespace NodeEditor { /// /// Class used as internal context of each node. /// - [TypeConverter(typeof(DynamicNodeContextConverter))] + [TypeConverter(typeof(DynamicNodeContextConverter))] public class DynamicNodeContext : DynamicObject, IEnumerable { private readonly IDictionary dynamicProperties = @@ -38,17 +42,17 @@ public class DynamicNodeContext : DynamicObject, IEnumerable internal byte[] Serialize() { - using (var bw = new BinaryWriter(new MemoryStream())) + using (BinaryWriter bw = new BinaryWriter(new MemoryStream())) { - foreach (var prop in dynamicProperties) + foreach (KeyValuePair prop in dynamicProperties) { - if (prop.Value.GetType().IsSerializable) + if (prop.Value != null && prop.Value.GetType().IsSerializable) { using (var ps = new MemoryStream()) { new BinaryFormatter().Serialize(ps, prop.Value); bw.Write(prop.Key); - bw.Write((int) ps.Length); + bw.Write((int)ps.Length); bw.Write(ps.ToArray()); } } @@ -60,7 +64,7 @@ internal byte[] Serialize() internal void Deserialize(byte[] data) { dynamicProperties.Clear(); - using (var br = new BinaryReader(new MemoryStream(data))) + using (BinaryReader br = new BinaryReader(new MemoryStream(data))) { while (br.BaseStream.Position < br.BaseStream.Length) { @@ -75,15 +79,105 @@ internal void Deserialize(byte[] data) } } + internal Dictionary GetPropertiesForSerialization() + { + Dictionary result = new Dictionary(); + foreach (KeyValuePair prop in dynamicProperties) + { + result[prop.Key] = new Serialization.ContextProperty + { + Value = prop.Value, + Type = prop.Value?.GetType().FullName ?? "", + ActualType = prop.Value?.GetType().AssemblyQualifiedName ?? "" // Store full type info for deserialization + }; + } + return result; + } + + internal void SetPropertiesFromSerialization(Dictionary properties, NodeVisual node) + { + dynamicProperties.Clear(); + + foreach (KeyValuePair prop in properties) + { + object propertyValue = prop.Value.Value; + if (propertyValue == null) + { + continue; + } + + string propertyName = prop.Key; + SocketVisual matchingSocket = node.GetSockets().Single(s => s.Name == propertyName); + + Type targetType; + try + { + targetType = Type.GetType(prop.Value.ActualType); + } + catch + { + // Fall back to socket type if actual type can't be loaded + targetType = matchingSocket.Type; + } + + // Handle reference types (ref/out parameters) - strip the & suffix + if (targetType.IsByRef) + { + targetType = targetType.GetElementType(); + } + + // Convert JToken types to appropriate CLR types + if (propertyValue is JToken jToken) + { + if (targetType.IsInterface) + { + // Map common interfaces to concrete types + if (targetType.IsGenericType) + { + Type genericDef = targetType.GetGenericTypeDefinition(); + if (genericDef == typeof(IEnumerable<>) || + genericDef == typeof(IList<>) || + genericDef == typeof(ICollection<>)) + { + // Use List for these interfaces + Type elementType = targetType.GetGenericArguments()[0]; + targetType = typeof(List<>).MakeGenericType(elementType); + } + else if (genericDef == typeof(IDictionary<,>)) + { + // Use Dictionary for IDictionary + Type[] genericArgs = targetType.GetGenericArguments(); + targetType = typeof(Dictionary<,>).MakeGenericType(genericArgs); + } + } + else if (targetType == typeof(IEnumerable)) + { + // Use object[] for non-generic IEnumerable + targetType = typeof(object[]); + } + } + + propertyValue = jToken.ToObject(targetType); + } + // Handle numeric type conversions for primitive types + else if (targetType.IsPrimitive) + { + propertyValue = Convert.ChangeType(propertyValue, targetType); + } + + dynamicProperties[propertyName] = propertyValue; + } + } + public override bool TryGetMember(GetMemberBinder binder, out object result) - { - var memberName = binder.Name; + { + string memberName = binder.Name; return dynamicProperties.TryGetValue(memberName, out result); } public override bool TrySetMember(SetMemberBinder binder, object value) { - var memberName = binder.Name; + string memberName = binder.Name; dynamicProperties[memberName] = value; return true; } @@ -91,7 +185,7 @@ public override bool TrySetMember(SetMemberBinder binder, object value) public override IEnumerable GetDynamicMemberNames() { return dynamicProperties.Keys; - } + } public object this[string key] { diff --git a/NodeEditor/DynamicNodeContextConverter.cs b/NodeEditor/DynamicNodeContextConverter.cs index a7698a3..9d0b3d2 100644 --- a/NodeEditor/DynamicNodeContextConverter.cs +++ b/NodeEditor/DynamicNodeContextConverter.cs @@ -31,13 +31,16 @@ public class DynamicNodeContextConverter : ExpandableObjectConverter { public override PropertyDescriptorCollection GetProperties(ITypeDescriptorContext context, object value, Attribute[] attributes) { - var obj = value as DynamicNodeContext; + DynamicNodeContext obj = value as DynamicNodeContext; List props = new List(); - var type = obj.GetType(); + Type type = obj.GetType(); foreach (var p in obj) { - var prop = new DynamicPropertyDescriptor(p, obj[p].GetType(), typeof (DynamicNodeContext)); - props.Add(prop); + if (obj[p] != null) + { + DynamicPropertyDescriptor prop = new DynamicPropertyDescriptor(p, obj[p].GetType(), typeof(DynamicNodeContext)); + props.Add(prop); + } } return new PropertyDescriptorCollection(props.ToArray()); diff --git a/NodeEditor/FlowControls/ForeachFlowControl.cs b/NodeEditor/FlowControls/ForeachFlowControl.cs new file mode 100644 index 0000000..596d35e --- /dev/null +++ b/NodeEditor/FlowControls/ForeachFlowControl.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace NodeEditor.FlowControls +{ + /// + /// Flow control implementation for map operations that transform each item in a collection + /// + public class ForEachFlowControl : IFlowControlNode + { + public void ExecuteFlowControl( + INodesContext context, + DynamicNodeContext nodeContext, + Action executeOutputPath, + Func shouldBreak) + { + // Get the collection from the node context + IEnumerable collection = nodeContext["inputCollection"] as IEnumerable; + + if (collection == null) + { + // If no collection, output empty array + nodeContext["forEachResult"] = Array.Empty(); + executeOutputPath("Exit"); + return; + } + + // List to collect transformed results + List results = new List(); + int index = 0; + + // Iterate through the collection + foreach (var item in collection) + { + // Check if we should break execution + if (shouldBreak()) + { + break; + } + + // Set the current item in the context + nodeContext["currentItemInLoop"] = item; + + // Clear the transformed item from previous iteration + nodeContext["loopResult"] = null; + + // Execute the transform path - this should set transformedItem + executeOutputPath("forEachItemLoop"); + + // Collect the transformed result + object transformedItem = nodeContext["loopResult"]; + if (transformedItem != null) + { + results.Add(transformedItem); + } + else + { + // If no transformation provided, use the original item + results.Add(item); + } + + index++; + } + + // Set the final results array + nodeContext["forEachResult"] = results.ToArray(); + + // Execute the exit path with the complete results + executeOutputPath("Exit"); + } + } +} \ No newline at end of file diff --git a/NodeEditor/IFlowControlNode.cs b/NodeEditor/IFlowControlNode.cs new file mode 100644 index 0000000..d468979 --- /dev/null +++ b/NodeEditor/IFlowControlNode.cs @@ -0,0 +1,23 @@ +using System; + +namespace NodeEditor +{ + /// + /// Interface for nodes that control execution flow (loops, conditionals, etc.) + /// + public interface IFlowControlNode + { + /// + /// Executes the flow control logic for this node + /// + /// The nodes context + /// The dynamic context containing node inputs/outputs + /// Callback to execute a named output execution path + /// Function to check if execution should be interrupted + void ExecuteFlowControl( + INodesContext context, + DynamicNodeContext nodeContext, + Action executeOutputPath, + Func shouldBreak); + } +} \ No newline at end of file diff --git a/NodeEditor/LoopFeedbackAttribute.cs b/NodeEditor/LoopFeedbackAttribute.cs new file mode 100644 index 0000000..fdd72e2 --- /dev/null +++ b/NodeEditor/LoopFeedbackAttribute.cs @@ -0,0 +1,15 @@ +using System; + +namespace NodeEditor +{ + /// + /// Marks a parameter as a loop feedback input that should not be resolved during normal resolution + /// + [AttributeUsage(AttributeTargets.Parameter)] + public class LoopFeedbackAttribute : Attribute + { + public LoopFeedbackAttribute() + { + } + } +} \ No newline at end of file diff --git a/NodeEditor/NodeAttribute.cs b/NodeEditor/NodeAttribute.cs index 836bac4..3d0b2b0 100644 --- a/NodeEditor/NodeAttribute.cs +++ b/NodeEditor/NodeAttribute.cs @@ -84,6 +84,11 @@ public class NodeAttribute : Attribute /// public int Height { get; set; } + /// + /// Type that implements IFlowControlNode for custom flow control logic + /// + public Type FlowControlHandler { get; set; } + /// /// Attribute for exposing method as node. /// @@ -97,9 +102,10 @@ public class NodeAttribute : Attribute /// Name that will be used in the xml export of the graph. /// Width of single node, or Auto if not determined /// Height of single node, or Auto if not determined + /// Type that implements IFlowControlNode for custom flow control logic public NodeAttribute(string name = "Node", string menu = "", string category = "General", string description = "Some node.", bool isCallable = true, bool isExecutionInitiator = false, Type customEditor = null, string xmlExportName = "", - int width = Auto, int height = Auto) + int width = Auto, int height = Auto, Type flowControlHandler = null) { Name = name; Menu = menu; @@ -111,6 +117,7 @@ public NodeAttribute(string name = "Node", string menu = "", string category = " XmlExportName = xmlExportName; Width = width; Height = height; + FlowControlHandler = flowControlHandler; } /// diff --git a/NodeEditor/NodeEditor.csproj b/NodeEditor/NodeEditor.csproj index 97e866f..0eae7c4 100644 --- a/NodeEditor/NodeEditor.csproj +++ b/NodeEditor/NodeEditor.csproj @@ -33,6 +33,9 @@ false + + ..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll + @@ -48,7 +51,10 @@ + + + @@ -65,6 +71,7 @@ True Resources.resx + diff --git a/NodeEditor/NodeVisual.cs b/NodeEditor/NodeVisual.cs index 4c16d8e..ca2bc84 100644 --- a/NodeEditor/NodeVisual.cs +++ b/NodeEditor/NodeVisual.cs @@ -58,7 +58,8 @@ public class NodeVisual internal bool ExecInit { get; set; } internal bool IsSelected { get; set; } internal FeedbackType Feedback { get; set; } - private object nodeContext { get; set; } + internal IFlowControlNode FlowControlHandler { get; set; } + private DynamicNodeContext nodeContext { get; set; } public Control CustomEditor { get; internal set; } internal string GUID = Guid.NewGuid().ToString(); internal Color NodeColor = Color.LightCyan; @@ -91,11 +92,11 @@ internal SocketVisual[] GetSockets() return socketCache; } - var socketList = new List(); + List socketList = new List(); float curInputH = HeaderHeight + ComponentPadding; float curOutputH = HeaderHeight + ComponentPadding; - var NodeWidth = GetNodeBounds().Width; + float NodeWidth = GetNodeBounds().Width; if (Callable) { @@ -142,7 +143,7 @@ internal SocketVisual[] GetSockets() curInputH += SocketVisual.SocketHeight + ComponentPadding; } - var ctx = GetNodeContext() as DynamicNodeContext; + DynamicNodeContext ctx = GetNodeContext(); foreach (var output in GetOutputs()) { var socket = new SocketVisual(); @@ -170,7 +171,7 @@ internal void DiscardCache() /// /// Returns node context which is dynamic type. It will contain all node default input/output properties. /// - public object GetNodeContext() + public DynamicNodeContext GetNodeContext() { if (nodeContext == null) { @@ -411,7 +412,7 @@ internal void Execute(INodesContext context) { context.CurrentProcessingNode = this; - var dc = (GetNodeContext() as DynamicNodeContext); + DynamicNodeContext dc = GetNodeContext(); var parametersDict = Type.GetParameters().OrderBy(x => x.Position).ToDictionary(x => x.Name, x => dc[x.Name]); var parameters = parametersDict.Values.ToArray(); diff --git a/NodeEditor/NodesControl.cs b/NodeEditor/NodesControl.cs index da340dc..8ec33eb 100644 --- a/NodeEditor/NodesControl.cs +++ b/NodeEditor/NodesControl.cs @@ -17,9 +17,8 @@ using System; using System.Collections.Generic; -using System.ComponentModel; -using System.Drawing; using System.Data; +using System.Drawing; using System.Drawing.Drawing2D; using System.IO; using System.Linq; @@ -28,6 +27,10 @@ using System.Windows.Forms; using System.Xml; +using Newtonsoft.Json; + +using NodeEditor.Serialization; + namespace NodeEditor { /// @@ -92,7 +95,7 @@ public INodesContext Context /// public event Action OnShowLocation = delegate { }; - private readonly Dictionary allContextItems = new Dictionary(); + private readonly Dictionary allContextItems = new Dictionary(); private Point lastMouseLocation; @@ -104,7 +107,7 @@ public INodesContext Context private INodesContext context; - private bool breakExecution = false; + private bool breakExecution = false; /// /// Default constructor @@ -114,7 +117,7 @@ public NodesControl() InitializeComponent(); timer.Interval = 30; timer.Tick += TimerOnTick; - timer.Start(); + timer.Start(); KeyDown += OnKeyDown; SetStyle(ControlStyles.Selectable, true); } @@ -132,8 +135,8 @@ private void ContextOnFeedbackInfo(string message, NodeVisual nodeVisual, Feedba protected override void WndProc(ref Message m) { if (m.Msg == 7) - { - return; + { + return; } base.WndProc(ref m); } @@ -158,9 +161,9 @@ private void TimerOnTick(object sender, EventArgs eventArgs) private void NodesControl_Paint(object sender, PaintEventArgs e) { e.Graphics.SmoothingMode = SmoothingMode.HighQuality; - e.Graphics.InterpolationMode = InterpolationMode.HighQualityBilinear; + e.Graphics.InterpolationMode = InterpolationMode.HighQualityBilinear; - graph.Draw(e.Graphics, PointToClient(MousePosition), MouseButtons); + graph.Draw(e.Graphics, PointToClient(MousePosition), MouseButtons); if (dragSocket != null) { @@ -195,7 +198,7 @@ private void NodesControl_MouseMove(object sender, MouseEventArgs e) selectionEnd = e.Location; } if (mdown) - { + { foreach (var node in graph.Nodes.Where(x => x.IsSelected)) { node.X += em.X - lastmpos.X; @@ -206,18 +209,18 @@ private void NodesControl_MouseMove(object sender, MouseEventArgs e) if (graph.Nodes.Exists(x => x.IsSelected)) { var n = graph.Nodes.FirstOrDefault(x => x.IsSelected); - var bound = new RectangleF(new PointF(n.X,n.Y), n.GetNodeBounds()); - foreach (var node in graph.Nodes.Where(x=>x.IsSelected)) + var bound = new RectangleF(new PointF(n.X, n.Y), n.GetNodeBounds()); + foreach (var node in graph.Nodes.Where(x => x.IsSelected)) { bound = RectangleF.Union(bound, new RectangleF(new PointF(node.X, node.Y), node.GetNodeBounds())); } OnShowLocation(bound); } Invalidate(); - + if (dragSocket != null) { - var center = new PointF(dragSocket.X + dragSocket.Width/2f, dragSocket.Y + dragSocket.Height/2f); + var center = new PointF(dragSocket.X + dragSocket.Width / 2f, dragSocket.Y + dragSocket.Height / 2f); if (dragSocket.Input) { dragConnectionBegin.X += em.X - lastmpos.X; @@ -232,19 +235,19 @@ private void NodesControl_MouseMove(object sender, MouseEventArgs e) dragConnectionEnd.Y += em.Y - lastmpos.Y; OnShowLocation(new RectangleF(dragConnectionEnd, new SizeF(10, 10))); } - + } lastmpos = em; - } + } needRepaint = true; } private void NodesControl_MouseDown(object sender, MouseEventArgs e) - { + { if (e.Button == MouseButtons.Left) { - selectionStart = PointF.Empty; + selectionStart = PointF.Empty; Focus(); @@ -259,9 +262,9 @@ private void NodesControl_MouseDown(object sender, MouseEventArgs e) if (node != null && !mdown) { - + node.IsSelected = true; - + node.Order = graph.Nodes.Min(x => x.Order) - 1; if (node.CustomEditor != null) { @@ -346,20 +349,20 @@ private bool IsConnectable(SocketVisual a, SocketVisual b) var otype = Type.GetType(output.Type.FullName.Replace("&", ""), AssemblyResolver, TypeResolver); var itype = Type.GetType(input.Type.FullName.Replace("&", ""), AssemblyResolver, TypeResolver); if (otype == null || itype == null) return false; - + // Check for exact match if (otype == itype) return true; - + // Check for inheritance if (otype.IsSubclassOf(itype)) return true; - + // Check for interface implementation if (itype.IsInterface && itype.IsAssignableFrom(otype)) return true; - + // Special case: Check if output type can be assigned to input type // This handles cases like string[] to IEnumerable if (itype.IsAssignableFrom(otype)) return true; - + return false; } @@ -401,8 +404,8 @@ private void NodesControl_MouseUp(object sender, MouseEventArgs e) var socket = nodeWhole.GetSockets().FirstOrDefault(x => x.GetBounds().Contains(e.Location)); if (socket != null) { - if (IsConnectable(dragSocket,socket) && dragSocket.Input != socket.Input) - { + if (IsConnectable(dragSocket, socket) && dragSocket.Input != socket.Input) + { var nc = new NodeConnection(); if (!dragSocket.Input) { @@ -428,7 +431,7 @@ private void NodesControl_MouseUp(object sender, MouseEventArgs e) } } } - + dragSocket = null; mdown = false; needRepaint = true; @@ -436,13 +439,13 @@ private void NodesControl_MouseUp(object sender, MouseEventArgs e) private void AddToMenu(ToolStripItemCollection items, NodeToken token, string path, EventHandler click) { - var pathParts = path.Split(new[] {'/'}, StringSplitOptions.RemoveEmptyEntries); + var pathParts = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); var first = pathParts.FirstOrDefault(); ToolStripMenuItem item = null; if (!items.ContainsKey(first)) { item = new ToolStripMenuItem(first); - item.Name = first; + item.Name = first; item.Tag = token; items.Add(item); } @@ -489,13 +492,13 @@ private void NodesControl_MouseClick(object sender, MouseEventArgs e) { Method = x, Attribute = - x.GetCustomAttributes(typeof (NodeAttribute), false) + x.GetCustomAttributes(typeof(NodeAttribute), false) .Cast() .FirstOrDefault() }).Where(x => x.Attribute != null); var context = new ContextMenuStrip(); - if (graph.Nodes.Exists(x=>x.IsSelected)) + if (graph.Nodes.Exists(x => x.IsSelected)) { context.Items.Add("Delete Node(s)", null, ((o, args) => { @@ -509,12 +512,12 @@ private void NodesControl_MouseClick(object sender, MouseEventArgs e) { ChangeSelectedNodesColor(); })); - if(graph.Nodes.Count(x=>x.IsSelected)==2) + if (graph.Nodes.Count(x => x.IsSelected) == 2) { var sel = graph.Nodes.Where(x => x.IsSelected).ToArray(); - context.Items.Add("Check Impact", null, ((o,args)=> + context.Items.Add("Check Impact", null, ((o, args) => { - if(HasImpact(sel[0],sel[1]) || HasImpact(sel[1],sel[0])) + if (HasImpact(sel[0], sel[1]) || HasImpact(sel[1], sel[0])) { MessageBox.Show("One node has impact on other.", "Impact detected.", MessageBoxButtons.OK, MessageBoxIcon.Information); } @@ -522,7 +525,7 @@ private void NodesControl_MouseClick(object sender, MouseEventArgs e) { MessageBox.Show("These nodes not impacts themselves.", "No impact.", MessageBoxButtons.OK, MessageBoxIcon.Information); } - })); + })); } context.Items.Add(new ToolStripSeparator()); } @@ -535,45 +538,54 @@ private void NodesControl_MouseClick(object sender, MouseEventArgs e) } context.Items.Add(new ToolStripSeparator()); } - foreach (var node in nodes.OrderBy(x=>x.Attribute.Path)) + foreach (var node in nodes.OrderBy(x => x.Attribute.Path)) { - AddToMenu(context.Items, node, node.Attribute.Path, (s,ev) => + AddToMenu(context.Items, node, node.Attribute.Path, (s, ev) => { - var tag = (s as ToolStripMenuItem).Tag as NodeToken; - - var nv = new NodeVisual(); - nv.X = lastMouseLocation.X; - nv.Y = lastMouseLocation.Y; - nv.Type = node.Method; - nv.Callable = node.Attribute.IsCallable; - nv.Name = node.Attribute.Name; - nv.Order = graph.Nodes.Count; - nv.ExecInit = node.Attribute.IsExecutionInitiator; - nv.XmlExportName = node.Attribute.XmlExportName; - nv.CustomWidth = node.Attribute.Width; - nv.CustomHeight = node.Attribute.Height; - - if (node.Attribute.CustomEditor != null) - { - Control ctrl = null; - nv.CustomEditor = ctrl = Activator.CreateInstance(node.Attribute.CustomEditor) as Control; - if (ctrl != null) - { - ctrl.Tag = nv; - Controls.Add(ctrl); - } - nv.LayoutEditor(); - } - - graph.Nodes.Add(nv); - Refresh(); - needRepaint = true; - }); + AddNodeToGraph(node); + }); } context.Show(MousePosition); } } + private void AddNodeToGraph(NodeToken node) + { + NodeVisual nv = new NodeVisual(); + nv.X = lastMouseLocation.X; + nv.Y = lastMouseLocation.Y; + nv.Type = node.Method; + nv.Callable = node.Attribute.IsCallable; + nv.Name = node.Attribute.Name; + nv.Order = graph.Nodes.Count; + nv.ExecInit = node.Attribute.IsExecutionInitiator; + nv.XmlExportName = node.Attribute.XmlExportName; + nv.CustomWidth = node.Attribute.Width; + nv.CustomHeight = node.Attribute.Height; + + // Create flow control handler if specified + if (node.Attribute.FlowControlHandler != null) + { + nv.FlowControlHandler = Activator.CreateInstance(node.Attribute.FlowControlHandler) as IFlowControlNode; + } + + if (node.Attribute.CustomEditor != null) + { + Control ctrl = null; + nv.CustomEditor = ctrl = Activator.CreateInstance(node.Attribute.CustomEditor) as Control; + if (ctrl != null) + { + ctrl.Tag = nv; + Controls.Add(ctrl); + } + nv.LayoutEditor(); + } + + graph.Nodes.Add(nv); + Refresh(); + needRepaint = true; + } + private void ChangeSelectedNodesColor() { ColorDialog cd = new ColorDialog(); @@ -635,8 +647,77 @@ private void DeleteSelectedNodes() /// Executes whole node graph (when called parameterless) or given node when specified. /// /// + private void ExecuteFlowControlNode(NodeVisual flowControlNode, Queue nodeQueue) + { + if (flowControlNode.FlowControlHandler == null) + return; + + DynamicNodeContext dc = flowControlNode.GetNodeContext(); + + // First execute the node to initialize outputs + flowControlNode.Execute(Context); + + // Create the executeOutputPath callback + Action executeOutputPath = (outputName) => + { + NodeConnection connection = graph.Connections.FirstOrDefault( + x => x.OutputNode == flowControlNode && x.OutputSocketName == outputName); + + if (connection != null) + { + if (outputName == "Exit") + { + // Queue for later execution + nodeQueue.Enqueue(connection.InputNode); + } + else + { + // Execute immediately (for loop body, etc.) + Execute(connection.InputNode); + + // After execution, transfer values back to the flow control node + // Look for connections that feed back into the flow control node + foreach (NodeConnection feedbackConnection in graph.Connections) + { + if (feedbackConnection.InputNode == flowControlNode) + { + // Check if this is a feedback input (marked with LoopFeedback attribute) + ParameterInfo inputParam = flowControlNode.GetInputs() + .FirstOrDefault(x => x.Name == feedbackConnection.InputSocketName); + + if (inputParam != null && + inputParam.GetCustomAttributes(typeof(LoopFeedbackAttribute), false).Any()) + { + // Transfer the value from the output node to the flow control node + DynamicNodeContext outputContext = feedbackConnection.OutputNode.GetNodeContext(); + dc[feedbackConnection.InputSocketName] = outputContext[feedbackConnection.OutputSocketName]; + } + } + } + } + } + }; + + // Create the shouldBreak callback + Func shouldBreak = () => breakExecution; + + // Execute the flow control logic + flowControlNode.FlowControlHandler.ExecuteFlowControl( + Context, + dc, + executeOutputPath, + shouldBreak); + + // Clear break flag if it was set + if (breakExecution) + { + breakExecution = false; + executionStack.Clear(); + } + } + public void Execute(NodeVisual node = null) - { + { var nodeQueue = new Queue(); nodeQueue.Enqueue(node); @@ -656,32 +737,43 @@ public void Execute(NodeVisual node = null) init.Feedback = FeedbackType.Debug; Resolve(init); - init.Execute(Context); - - var connection = - graph.Connections.FirstOrDefault( - x => x.OutputNode == init && x.IsExecution && x.OutputSocket.Value != null && (x.OutputSocket.Value as ExecutionPath).IsSignaled); - if (connection == null) - { - connection = graph.Connections.FirstOrDefault(x => x.OutputNode == init && x.IsExecution && x.OutputSocket.IsMainExecution); - } - else - { - executionStack.Push(init); - } - if (connection != null) + + // Check if this node has a flow control handler + if (init.FlowControlHandler != null) { - connection.InputNode.IsBackExecuted = false; - //Execute(connection.InputNode); - nodeQueue.Enqueue(connection.InputNode); + // Handle flow control node + ExecuteFlowControlNode(init, nodeQueue); } else { - if (executionStack.Count > 0) + // Normal node execution + init.Execute(Context); + + var connection = + graph.Connections.FirstOrDefault( + x => x.OutputNode == init && x.IsExecution && x.OutputSocket.Value != null && (x.OutputSocket.Value as ExecutionPath).IsSignaled); + if (connection == null) { - var back = executionStack.Pop(); - back.IsBackExecuted = true; - Execute(back); + connection = graph.Connections.FirstOrDefault(x => x.OutputNode == init && x.IsExecution && x.OutputSocket.IsMainExecution); + } + else + { + executionStack.Push(init); + } + if (connection != null) + { + connection.InputNode.IsBackExecuted = false; + //Execute(connection.InputNode); + nodeQueue.Enqueue(connection.InputNode); + } + else + { + if (executionStack.Count > 0) + { + var back = executionStack.Pop(); + back.IsBackExecuted = true; + Execute(back); + } } } } @@ -699,12 +791,12 @@ public bool HasImpact(NodeVisual startNode, NodeVisual endNode) var connections = graph.Connections.Where(x => x.OutputNode == startNode && !x.IsExecution); foreach (var connection in connections) { - if(connection.InputNode == endNode) + if (connection.InputNode == endNode) { return true; } bool nextImpact = HasImpact(connection.InputNode, endNode); - if(nextImpact) + if (nextImpact) { return true; } @@ -725,20 +817,26 @@ public void ExecuteResolving(params string[] nodeNames) private void ExecuteResolvingInternal(NodeVisual node) { - var icontext = (node.GetNodeContext() as DynamicNodeContext); + DynamicNodeContext icontext = node.GetNodeContext(); foreach (var input in node.GetInputs()) { + // Skip inputs marked with LoopFeedback attribute + if (input.GetCustomAttributes(typeof(LoopFeedbackAttribute), false).Any()) + { + continue; + } + var connection = graph.Connections.FirstOrDefault(x => x.InputNode == node && x.InputSocketName == input.Name); if (connection != null) { Resolve(connection.OutputNode); - + connection.OutputNode.Execute(Context); ExecuteResolvingInternal(connection.OutputNode); - - var ocontext = (connection.OutputNode.GetNodeContext() as DynamicNodeContext); + + DynamicNodeContext ocontext = connection.OutputNode.GetNodeContext(); icontext[connection.InputSocketName] = ocontext[connection.OutputSocketName]; } } @@ -750,27 +848,33 @@ private void ExecuteResolvingInternal(NodeVisual node) /// private void Resolve(NodeVisual node) { - var icontext = (node.GetNodeContext() as DynamicNodeContext); + DynamicNodeContext icontext = node.GetNodeContext(); foreach (var input in node.GetInputs()) { + // Skip inputs marked with LoopFeedback attribute + if (input.GetCustomAttributes(typeof(LoopFeedbackAttribute), false).Any()) + { + continue; + } + var connection = GetConnection(node.GUID + input.Name); - //graph.Connections.FirstOrDefault(x => x.InputNode == node && x.InputSocketName == input.Name); + //graph.Connections.FirstOrDefault(x => x.InputNode == node && x.InputSocketName == input.Name); if (connection != null) { Resolve(connection.OutputNode); if (!connection.OutputNode.Callable) - { + { connection.OutputNode.Execute(Context); } - var ocontext = (connection.OutputNode.GetNodeContext() as DynamicNodeContext); - icontext[connection.InputSocketName] = ocontext[connection.OutputSocketName]; + DynamicNodeContext ocontext = connection.OutputNode.GetNodeContext(); + icontext[connection.InputSocketName] = ocontext[connection.OutputSocketName]; } } } private NodeConnection GetConnection(string v) { - if(rebuildConnectionDictionary) + if (rebuildConnectionDictionary) { rebuildConnectionDictionary = false; connectionDictionary.Clear(); @@ -800,7 +904,7 @@ public string ExportToXml() xmlNode.SetAttribute("Name", node.XmlExportName); xmlNode.SetAttribute("Id", node.GetGuid()); var xmlContext = (XmlElement)xmlNode.AppendChild(xml.CreateElement("Context")); - var context = node.GetNodeContext() as DynamicNodeContext; + DynamicNodeContext context = node.GetNodeContext(); foreach (var kv in context) { var ce = (XmlElement)xmlContext.AppendChild(xml.CreateElement("ContextMember")); @@ -861,7 +965,7 @@ public byte[] Serialize() return (bw.BaseStream as MemoryStream).ToArray(); } } - + private static void SerializeNode(BinaryWriter bw, NodeVisual node) { bw.Write(node.GUID); @@ -882,7 +986,7 @@ private static void SerializeNode(BinaryWriter bw, NodeVisual node) bw.Write(node.CustomEditor.GetType().FullName); } bw.Write(node.Type.Name); - var context = (node.GetNodeContext() as DynamicNodeContext).Serialize(); + byte[] context = node.GetNodeContext().Serialize(); bw.Write(context.Length); bw.Write(context); bw.Write(8); //additional data size per node @@ -935,7 +1039,7 @@ public void Deserialize(byte[] data) private NodeVisual DeserializeNode(BinaryReader br) { - var nv = new NodeVisual(); + NodeVisual nv = new NodeVisual(); nv.GUID = br.ReadString(); nv.X = br.ReadSingle(); nv.Y = br.ReadSingle(); @@ -949,17 +1053,17 @@ private NodeVisual DeserializeNode(BinaryReader br) var attribute = nv.Type.GetCustomAttributes(typeof(NodeAttribute), false) .Cast() .FirstOrDefault(); - if(attribute!=null) + if (attribute != null) { nv.CustomWidth = attribute.Width; nv.CustomHeight = attribute.Height; } - (nv.GetNodeContext() as DynamicNodeContext).Deserialize(br.ReadBytes(br.ReadInt32())); + nv.GetNodeContext().Deserialize(br.ReadBytes(br.ReadInt32())); var additional = br.ReadInt32(); //read additional data if (additional >= 4) { nv.Int32Tag = br.ReadInt32(); - if(additional >= 8) + if (additional >= 8) { nv.NodeColor = Color.FromArgb(br.ReadInt32()); } @@ -996,5 +1100,179 @@ public void Clear() Refresh(); rebuildConnectionDictionary = true; } + + /// + /// Serializes current node graph to JSON string. + /// + public string SerializeToJson() + { + NodeGraphModel model = new NodeGraphModel(); + model.Version = 1001; + + // Serialize nodes + foreach (NodeVisual node in graph.Nodes) + { + NodeModel nodeModel = new NodeModel + { + Guid = node.GUID, + X = node.X, + Y = node.Y, + Callable = node.Callable, + ExecInit = node.ExecInit, + Name = node.Name, + Order = node.Order, + XmlExportName = node.XmlExportName, + CustomWidth = node.CustomWidth, + CustomHeight = node.CustomHeight, + Int32Tag = node.Int32Tag, + NodeColor = node.NodeColor.ToArgb(), + MethodName = node.Type?.Name, + Context = node.GetNodeContext().GetPropertiesForSerialization() + }; + + // Add custom editor info if present + if (node.CustomEditor != null) + { + nodeModel.CustomEditor = new CustomEditorInfo + { + AssemblyName = node.CustomEditor.GetType().Assembly.GetName().Name, + TypeName = node.CustomEditor.GetType().FullName + }; + } + + // Add flow control handler if present + if (node.FlowControlHandler != null) + { + nodeModel.FlowControlHandler = node.FlowControlHandler.GetType().FullName; + } + + model.Nodes.Add(nodeModel); + } + + // Serialize connections + foreach (var connection in graph.Connections) + { + var connModel = new ConnectionModel + { + OutputNodeId = connection.OutputNode.GUID, + OutputSocketName = connection.OutputSocketName, + InputNodeId = connection.InputNode.GUID, + InputSocketName = connection.InputSocketName + }; + model.Connections.Add(connModel); + } + + return JsonConvert.SerializeObject(model, Newtonsoft.Json.Formatting.Indented); + } + + /// + /// Deserializes node graph from JSON string. + /// + public void DeserializeFromJson(string json) + { + var model = JsonConvert.DeserializeObject(json); + if (model == null) + return; + + rebuildConnectionDictionary = true; + graph.Connections.Clear(); + graph.Nodes.Clear(); + Controls.Clear(); + + // Deserialize nodes + foreach (NodeModel nodeModel in model.Nodes) + { + NodeVisual node = new NodeVisual(); + node.GUID = nodeModel.Guid ?? Guid.NewGuid().ToString(); + node.X = nodeModel.X; + node.Y = nodeModel.Y; + node.Callable = nodeModel.Callable; + node.ExecInit = nodeModel.ExecInit; + node.Name = nodeModel.Name; + node.Order = nodeModel.Order; + node.XmlExportName = nodeModel.XmlExportName; + node.CustomWidth = nodeModel.CustomWidth; + node.CustomHeight = nodeModel.CustomHeight; + node.Int32Tag = nodeModel.Int32Tag; + node.NodeColor = Color.FromArgb(nodeModel.NodeColor); + + // Set method + if (!string.IsNullOrEmpty(nodeModel.MethodName) && Context != null) + { + node.Type = Context.GetType().GetMethod(nodeModel.MethodName); + + if (node.Type != null) + { + NodeAttribute attribute = node.Type.GetCustomAttributes(typeof(NodeAttribute), false) + .Cast() + .FirstOrDefault(); + if (attribute != null) + { + if (node.CustomWidth == 0) + node.CustomWidth = attribute.Width; + if (node.CustomHeight == 0) + node.CustomHeight = attribute.Height; + + // Create flow control handler if specified + if (attribute.FlowControlHandler != null) + { + node.FlowControlHandler = Activator.CreateInstance(attribute.FlowControlHandler) as IFlowControlNode; + } + } + } + } + + // Set context + if (nodeModel.Context != null) + { + node.GetNodeContext().SetPropertiesFromSerialization(nodeModel.Context, node); + } + + // Create custom editor + if (nodeModel.CustomEditor != null && + !string.IsNullOrEmpty(nodeModel.CustomEditor.AssemblyName) && + !string.IsNullOrEmpty(nodeModel.CustomEditor.TypeName)) + { + try + { + node.CustomEditor = Activator.CreateInstance( + AppDomain.CurrentDomain, + nodeModel.CustomEditor.AssemblyName, + nodeModel.CustomEditor.TypeName).Unwrap() as Control; + + if (node.CustomEditor != null) + { + node.CustomEditor.Tag = node; + Controls.Add(node.CustomEditor); + node.LayoutEditor(); + } + } + catch + { + throw; + } + } + + graph.Nodes.Add(node); + } + + // Deserialize connections + foreach (var connModel in model.Connections) + { + var connection = new NodeConnection(); + connection.OutputNode = graph.Nodes.FirstOrDefault(x => x.GUID == connModel.OutputNodeId); + connection.OutputSocketName = connModel.OutputSocketName; + connection.InputNode = graph.Nodes.FirstOrDefault(x => x.GUID == connModel.InputNodeId); + connection.InputSocketName = connModel.InputSocketName; + + if (connection.OutputNode != null && connection.InputNode != null) + { + graph.Connections.Add(connection); + } + } + + rebuildConnectionDictionary = true; + Refresh(); + } } } diff --git a/NodeEditor/Serialization/NodeGraphModel.cs b/NodeEditor/Serialization/NodeGraphModel.cs new file mode 100644 index 0000000..83f037e --- /dev/null +++ b/NodeEditor/Serialization/NodeGraphModel.cs @@ -0,0 +1,144 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace NodeEditor.Serialization +{ + /// + /// Root model for serializing the entire node graph + /// + public class NodeGraphModel + { + [JsonProperty("version")] + public int Version { get; set; } = 1001; + + [JsonProperty("nodes")] + public List Nodes { get; set; } = new List(); + + [JsonProperty("connections")] + public List Connections { get; set; } = new List(); + + [JsonProperty("metadata")] + public GraphMetadata Metadata { get; set; } = new GraphMetadata(); + } + + /// + /// Model for serializing individual nodes + /// + public class NodeModel + { + [JsonProperty("guid")] + public string Guid { get; set; } + + [JsonProperty("x")] + public float X { get; set; } + + [JsonProperty("y")] + public float Y { get; set; } + + [JsonProperty("callable")] + public bool Callable { get; set; } + + [JsonProperty("execInit")] + public bool ExecInit { get; set; } + + [JsonProperty("name")] + public string Name { get; set; } + + [JsonProperty("order")] + public int Order { get; set; } + + [JsonProperty("xmlExportName")] + public string XmlExportName { get; set; } + + [JsonProperty("customWidth")] + public int CustomWidth { get; set; } + + [JsonProperty("customHeight")] + public int CustomHeight { get; set; } + + [JsonProperty("int32Tag")] + public int Int32Tag { get; set; } + + [JsonProperty("nodeColor")] + public int NodeColor { get; set; } + + [JsonProperty("methodName")] + public string MethodName { get; set; } + + [JsonProperty("customEditor")] + public CustomEditorInfo CustomEditor { get; set; } + + [JsonProperty("context")] + public Dictionary Context { get; set; } = new Dictionary(); + + [JsonProperty("flowControlHandler")] + public string FlowControlHandler { get; set; } + } + + /// + /// Model for custom editor information + /// + public class CustomEditorInfo + { + [JsonProperty("assemblyName")] + public string AssemblyName { get; set; } + + [JsonProperty("typeName")] + public string TypeName { get; set; } + } + + /// + /// Model for context properties + /// + public class ContextProperty + { + [JsonProperty("value")] + public object Value { get; set; } + + [JsonProperty("type")] + public string Type { get; set; } + + [JsonProperty("actualType")] + public string ActualType { get; set; } // Store the actual runtime type for proper deserialization + } + + /// + /// Model for serializing connections + /// + public class ConnectionModel + { + [JsonProperty("outputNodeId")] + public string OutputNodeId { get; set; } + + [JsonProperty("outputSocketName")] + public string OutputSocketName { get; set; } + + [JsonProperty("inputNodeId")] + public string InputNodeId { get; set; } + + [JsonProperty("inputSocketName")] + public string InputSocketName { get; set; } + } + + /// + /// Model for graph metadata + /// + public class GraphMetadata + { + [JsonProperty("createdAt")] + public DateTime CreatedAt { get; set; } = DateTime.Now; + + [JsonProperty("modifiedAt")] + public DateTime ModifiedAt { get; set; } = DateTime.Now; + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("author")] + public string Author { get; set; } + + [JsonProperty("tags")] + public List Tags { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/NodeEditor/packages.config b/NodeEditor/packages.config new file mode 100644 index 0000000..8b1a8d0 --- /dev/null +++ b/NodeEditor/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file From c7bdd0d94651d1db9eeb7eefb5e67939bcde1064 Mon Sep 17 00:00:00 2001 From: Brian Whitenack Date: Fri, 12 Sep 2025 16:00:00 -0500 Subject: [PATCH 3/8] feat: TYPES TYPES TYPES --- .claude/settings.local.json | 4 +- MathSample/FormMathSample.cs | 2 +- MathSample/MathSample.csproj | 4 +- MathSample/Measurement.cs | 14 + MathSample/Part.cs | 17 + .../{MathContext.cs => PartCalculation.cs} | 52 ++- NodeEditor/DynamicTypeAttribute.cs | 63 +++ NodeEditor/FlowControls/ForeachFlowControl.cs | 90 +++- NodeEditor/NodeEditor.csproj | 2 + NodeEditor/NodeVisual.cs | 101 ++++- NodeEditor/NodesControl.cs | 387 ++++++++++++++++- NodeEditor/SocketVisual.cs | 161 +++++++- NodeEditor/TypePropagation.cs | 388 ++++++++++++++++++ 13 files changed, 1234 insertions(+), 51 deletions(-) create mode 100644 MathSample/Measurement.cs create mode 100644 MathSample/Part.cs rename MathSample/{MathContext.cs => PartCalculation.cs} (58%) create mode 100644 NodeEditor/DynamicTypeAttribute.cs create mode 100644 NodeEditor/TypePropagation.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c3afe87..0b49ed9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,9 @@ "Bash(nuget restore:*)", "Bash(dotnet restore:*)", "Bash(dotnet build)", - "Bash(msbuild:*)" + "Bash(msbuild:*)", + "Bash(dotnet build:*)", + "Bash(MSBuild.exe:*)" ], "deny": [], "ask": [] diff --git a/MathSample/FormMathSample.cs b/MathSample/FormMathSample.cs index 37daa10..cafa41c 100644 --- a/MathSample/FormMathSample.cs +++ b/MathSample/FormMathSample.cs @@ -13,7 +13,7 @@ namespace MathSample public partial class FormMathSample : Form { //Context that will be used for our nodes - MathContext context = new MathContext(); + PartCalculation context = new PartCalculation(); public FormMathSample() { diff --git a/MathSample/MathSample.csproj b/MathSample/MathSample.csproj index c445a03..f1f6f3a 100644 --- a/MathSample/MathSample.csproj +++ b/MathSample/MathSample.csproj @@ -53,7 +53,9 @@ FormMathSample.cs - + + + diff --git a/MathSample/Measurement.cs b/MathSample/Measurement.cs new file mode 100644 index 0000000..7a0ff95 --- /dev/null +++ b/MathSample/Measurement.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace MathSample +{ + public class Measurement + { + public string Type { get; set; } + public double Length { get; set; } + public double Area { get; set; } + public int Count { get; set; } + + public Dictionary Selections { get; set; } = new Dictionary(); + } +} diff --git a/MathSample/Part.cs b/MathSample/Part.cs new file mode 100644 index 0000000..c55eae7 --- /dev/null +++ b/MathSample/Part.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MathSample +{ + public class Part + { + public string Sku { get; set; } + public string Description { get; set; } + public string Package { get; set; } + public double Quantity { get; set; } + public string UnitOfMeasure { get; set; } + } +} diff --git a/MathSample/MathContext.cs b/MathSample/PartCalculation.cs similarity index 58% rename from MathSample/MathContext.cs rename to MathSample/PartCalculation.cs index 2af0b8e..6107044 100644 --- a/MathSample/MathContext.cs +++ b/MathSample/PartCalculation.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text; using System.Windows.Forms; using NodeEditor; @@ -11,18 +10,37 @@ namespace MathSample { // Main context of the sample, each // method corresponds to a node by attribute decoration - public class MathContext : INodesContext + public class PartCalculation : INodesContext { public NodeVisual CurrentProcessingNode { get; set; } public event Action FeedbackInfo; - [Node("String Value", "Input", "Basic", "Allows to output a simple string value.", false)] + [Node("Create Part", "Parts", "Basic", "Create a part", true)] + public void CreatePart(string sku, string description, string package, float quantity, string unitOfMeasure, out Part part) + { + part = new Part + { + Sku = sku, + Description = description, + Package = package, + Quantity = quantity, + UnitOfMeasure = unitOfMeasure + }; + } + + [Node("Parts List", "Parts", "Basic", "Create a list of parts", false)] + public void PartsList(ExecutionPath calculationEnd, IEnumerable parts) + { + + } + + [Node("String Value", "Constants", "Basic", "Allows to output a simple string value.", false)] public void StringValue(string inValue, out string outValue) { outValue = inValue; } - [Node("String List Value", "Input", "Basic", "Allows to output a simple string list value.", false)] + [Node("String List Value", "Constants", "Basic", "Allows to output a simple string list value.", false)] public void StringListValue(string[] inValue, out string[] outValue) { outValue = inValue; @@ -30,7 +48,13 @@ public void StringListValue(string[] inValue, out string[] outValue) [Node("For Each", "Loops", "Functional", "Transforms each item in a collection and returns the results.", true, flowControlHandler: typeof(ForEachFlowControl), Width = 250)] - public void ForEach(IEnumerable inputCollection, [LoopFeedback] object loopResult, out object currentItemInLoop, out ExecutionPath forEachItemLoop, out IEnumerable forEachResult) + [DynamicNode] + public void ForEach( + [DynamicType(TypeGroup = "InputType")] IEnumerable inputCollection, + [LoopFeedback][DynamicType(TypeGroup = "OutputType")] object loopResult, + [DynamicType(TypeGroup = "InputType", ExtractElementType = true, DerivedFrom = nameof(inputCollection))] out object currentItemInLoop, + out ExecutionPath forEachItemLoop, + [DynamicType(TypeGroup = "OutputType", WrapInCollection = true)] out IEnumerable forEachResult) { // Initialize outputs - actual values will be set by the flow control handler currentItemInLoop = null; @@ -38,37 +62,37 @@ public void ForEach(IEnumerable inputCollection, [LoopFeedback] object l forEachResult = null; } - [Node("Value", "Input", "Basic", "Allows to output a simple value.", false)] + [Node("Value", "Constants", "Basic", "Allows to output a simple value.", false)] public void InputValue(float inValue, out float outValue) { outValue = inValue; } - [Node("Add", "Operators", "Basic", "Adds two input values.", false)] + [Node("Add", "Math", "Basic", "Adds two input values.", false)] public void Add(float a, float b, out float result) { result = a + b; } - [Node("Subtract", "Operators", "Basic", "Substracts two input values.", true)] + [Node("Subtract", "Math", "Basic", "Substracts two input values.", true)] public void Subtract(float a, float b, out float result) { result = a - b; } - [Node("Multiply", "Operators", "Basic", "Multiplies two input values.", true)] + [Node("Multiply", "Math", "Basic", "Multiplies two input values.", true)] public void Multiply(float a, float b, out float result) { result = a * b; } - [Node("Divide", "Operators", "Basic", "Divides two input values.", true)] + [Node("Divide", "Math", "Basic", "Divides two input values.", true)] public void Divide(float a, float b, out float result) { result = a / b; } - [Node("Show Value", "Helper", "Basic", "Shows input value in the message box.")] + [Node("Show Value", "Debug", "Basic", "Shows input value in the message box.")] public void ShowMessageBox(object x) { string valueToShow; @@ -106,6 +130,12 @@ public void ToStringNode(object a, out string result) result = a?.ToString(); } + [Node("To List", "Operators", "List", "Converts to a string.", true)] + public void ToListNode(object a, out List list) + { + list = new List() { a }; + } + [Node("Starter", "Helper", "Basic", "Starts execution", true, true)] public void Starter() { diff --git a/NodeEditor/DynamicTypeAttribute.cs b/NodeEditor/DynamicTypeAttribute.cs new file mode 100644 index 0000000..1945231 --- /dev/null +++ b/NodeEditor/DynamicTypeAttribute.cs @@ -0,0 +1,63 @@ +using System; + +namespace NodeEditor +{ + /// + /// Marks a parameter as supporting dynamic type inference. + /// When applied, the parameter's type can be inferred from connected inputs. + /// + [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false)] + public class DynamicTypeAttribute : Attribute + { + /// + /// Gets or sets the name of the input parameter this output derives its type from. + /// If specified, this output will match the type (or element type for collections) of the specified input. + /// + public string DerivedFrom { get; set; } + + /// + /// Gets or sets whether this parameter extracts the element type from a collection. + /// When true, if the input is IEnumerable, this parameter becomes type T. + /// + public bool ExtractElementType { get; set; } + + /// + /// Gets or sets whether this parameter wraps the type in a collection. + /// When true, if the input is type T, this parameter becomes IEnumerable. + /// + public bool WrapInCollection { get; set; } + + /// + /// Gets or sets the group name for type propagation. + /// All parameters with the same group name will share the same inferred type. + /// + public string TypeGroup { get; set; } + + public DynamicTypeAttribute() + { + } + + public DynamicTypeAttribute(string derivedFrom) + { + DerivedFrom = derivedFrom; + } + } + + /// + /// Marks a method (node) as supporting dynamic type inference. + /// + [AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] + public class DynamicNodeAttribute : Attribute + { + /// + /// Gets or sets whether this node should propagate types through all its connections. + /// + public bool PropagateTypes { get; set; } = true; + + /// + /// Gets or sets whether incompatible connections should be automatically disconnected + /// when types change. + /// + public bool AutoDisconnectIncompatible { get; set; } = true; + } +} \ No newline at end of file diff --git a/NodeEditor/FlowControls/ForeachFlowControl.cs b/NodeEditor/FlowControls/ForeachFlowControl.cs index 596d35e..f76ce56 100644 --- a/NodeEditor/FlowControls/ForeachFlowControl.cs +++ b/NodeEditor/FlowControls/ForeachFlowControl.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Linq; namespace NodeEditor.FlowControls { @@ -9,6 +10,12 @@ namespace NodeEditor.FlowControls /// public class ForEachFlowControl : IFlowControlNode { + private const string INPUT_COLLECTION = "inputCollection"; + private const string CURRENT_ITEM_IN_LOOP = "currentItemInLoop"; + private const string LOOP_RESULT = "loopResult"; + private const string FOR_EACH_RESULT = "forEachResult"; + private const string FOR_EACH_ITEM_LOOP = "forEachItemLoop"; + private const string EXIT = "Exit"; public void ExecuteFlowControl( INodesContext context, DynamicNodeContext nodeContext, @@ -16,18 +23,39 @@ public void ExecuteFlowControl( Func shouldBreak) { // Get the collection from the node context - IEnumerable collection = nodeContext["inputCollection"] as IEnumerable; + IEnumerable collection = nodeContext[INPUT_COLLECTION] as IEnumerable; if (collection == null) { // If no collection, output empty array - nodeContext["forEachResult"] = Array.Empty(); - executeOutputPath("Exit"); + nodeContext[FOR_EACH_RESULT] = Array.Empty(); + executeOutputPath(EXIT); return; } - // List to collect transformed results - List results = new List(); + // Get the runtime type for forEachResult to create properly typed collection + Type resultType = typeof(object); + NodeVisual currentNode = context.CurrentProcessingNode; + if (currentNode != null) + { + Type runtimeType = currentNode.GetSocketRuntimeType(FOR_EACH_RESULT); + if (runtimeType != null && runtimeType.IsArray) + { + resultType = runtimeType.GetElementType(); + } + else if (runtimeType != null && runtimeType.IsGenericType) + { + Type[] genericArgs = runtimeType.GetGenericArguments(); + if (genericArgs.Length > 0) + { + resultType = genericArgs[0]; + } + } + } + + // Create a properly typed list for results + Type listType = typeof(List<>).MakeGenericType(resultType); + System.Collections.IList results = Activator.CreateInstance(listType) as System.Collections.IList; int index = 0; // Iterate through the collection @@ -40,16 +68,16 @@ public void ExecuteFlowControl( } // Set the current item in the context - nodeContext["currentItemInLoop"] = item; + nodeContext[CURRENT_ITEM_IN_LOOP] = item; // Clear the transformed item from previous iteration - nodeContext["loopResult"] = null; + nodeContext[LOOP_RESULT] = null; // Execute the transform path - this should set transformedItem - executeOutputPath("forEachItemLoop"); + executeOutputPath(FOR_EACH_ITEM_LOOP); // Collect the transformed result - object transformedItem = nodeContext["loopResult"]; + object transformedItem = nodeContext[LOOP_RESULT]; if (transformedItem != null) { results.Add(transformedItem); @@ -63,11 +91,49 @@ public void ExecuteFlowControl( index++; } - // Set the final results array - nodeContext["forEachResult"] = results.ToArray(); + // Set the final results in the appropriate format based on the expected output type + if (currentNode != null) + { + Type expectedOutputType = currentNode.GetSocketRuntimeType(FOR_EACH_RESULT); + if (expectedOutputType != null) + { + if (expectedOutputType.IsArray) + { + // Create properly typed array + Array typedArray = Array.CreateInstance(resultType, results.Count); + results.CopyTo(typedArray, 0); + nodeContext[FOR_EACH_RESULT] = typedArray; + } + else if (expectedOutputType.IsGenericType && + (expectedOutputType.GetGenericTypeDefinition() == typeof(IEnumerable<>) || + expectedOutputType.GetGenericTypeDefinition() == typeof(IList<>) || + expectedOutputType.GetGenericTypeDefinition() == typeof(ICollection<>))) + { + // For interface types, return the List we already created + nodeContext[FOR_EACH_RESULT] = results; + } + else + { + // Default to array for unknown types + Array typedArray = Array.CreateInstance(resultType, results.Count); + results.CopyTo(typedArray, 0); + nodeContext[FOR_EACH_RESULT] = typedArray; + } + } + else + { + // Fallback to object array + nodeContext[FOR_EACH_RESULT] = results.Cast().ToArray(); + } + } + else + { + // Fallback to object array + nodeContext[FOR_EACH_RESULT] = results.Cast().ToArray(); + } // Execute the exit path with the complete results - executeOutputPath("Exit"); + executeOutputPath(EXIT); } } } \ No newline at end of file diff --git a/NodeEditor/NodeEditor.csproj b/NodeEditor/NodeEditor.csproj index 0eae7c4..dc818df 100644 --- a/NodeEditor/NodeEditor.csproj +++ b/NodeEditor/NodeEditor.csproj @@ -49,6 +49,7 @@ + @@ -73,6 +74,7 @@ + diff --git a/NodeEditor/NodeVisual.cs b/NodeEditor/NodeVisual.cs index ca2bc84..5118261 100644 --- a/NodeEditor/NodeVisual.cs +++ b/NodeEditor/NodeVisual.cs @@ -65,6 +65,9 @@ public class NodeVisual internal Color NodeColor = Color.LightCyan; public bool IsBackExecuted { get; internal set; } private SocketVisual[] socketCache; + + // Dynamic typing support + private Dictionary socketTypeInfo = new Dictionary(); /// /// Tag for various puposes - may be used freely. @@ -128,9 +131,9 @@ internal SocketVisual[] GetSockets() curInputH += SocketVisual.SocketHeight + ComponentPadding; } - foreach (var input in GetInputs()) + foreach (ParameterInfo input in GetInputs()) { - var socket = new SocketVisual(); + SocketVisual socket = new SocketVisual(); socket.Type = input.ParameterType; socket.Height = SocketVisual.SocketHeight; socket.Name = input.Name; @@ -138,22 +141,43 @@ internal SocketVisual[] GetSockets() socket.X = X; socket.Y = Y + curInputH; socket.Input = true; + + // Use runtime type if available for dynamic sockets + if (socketTypeInfo.ContainsKey(input.Name)) + { + socket.RuntimeType = socketTypeInfo[input.Name].RuntimeType; + } + else + { + socket.RuntimeType = socket.Type; + } socketList.Add(socket); curInputH += SocketVisual.SocketHeight + ComponentPadding; } DynamicNodeContext ctx = GetNodeContext(); - foreach (var output in GetOutputs()) + foreach (ParameterInfo output in GetOutputs()) { - var socket = new SocketVisual(); + SocketVisual socket = new SocketVisual(); socket.Type = output.ParameterType; socket.Height = SocketVisual.SocketHeight; socket.Name = output.Name; socket.Width = SocketVisual.SocketHeight; socket.X = X + NodeWidth - SocketVisual.SocketHeight; socket.Y = Y + curOutputH; - socket.Value = ctx[socket.Name]; + socket.Value = ctx[socket.Name]; + + // Use runtime type if available for dynamic sockets + if (socketTypeInfo.ContainsKey(output.Name)) + { + socket.RuntimeType = socketTypeInfo[output.Name].RuntimeType; + } + else + { + socket.RuntimeType = socket.Type; + } + socketList.Add(socket); curOutputH += SocketVisual.SocketHeight + ComponentPadding; @@ -446,5 +470,72 @@ internal void LayoutEditor() CustomEditor.Location = new Point((int)( X + 1 + 40 + SocketVisual.SocketHeight), (int) (Y + HeaderHeight + 4)); } } + + /// + /// Updates the runtime type for a socket based on connected input + /// + internal void UpdateSocketType(string socketName, Type newType) + { + if (!socketTypeInfo.ContainsKey(socketName)) + { + // Initialize type info if not present + SocketVisual socket = GetSockets().FirstOrDefault(s => s.Name == socketName); + if (socket != null) + { + socketTypeInfo[socketName] = new SocketTypeInfo(socket.Type); + } + } + + if (socketTypeInfo.ContainsKey(socketName)) + { + socketTypeInfo[socketName].RuntimeType = newType; + DiscardCache(); // Force socket refresh + } + } + + /// + /// Gets the runtime type for a socket + /// + internal Type GetSocketRuntimeType(string socketName) + { + if (socketTypeInfo.ContainsKey(socketName)) + { + return socketTypeInfo[socketName].RuntimeType; + } + + SocketVisual socket = GetSockets().FirstOrDefault(s => s.Name == socketName); + return socket?.Type; + } + + /// + /// Propagates type information through the node using attribute-based system + /// + internal void PropagateTypes(NodesGraph graph) + { + TypePropagation.PropagateNodeTypes(this, graph); + } + + /// + /// Resets socket types to their original static types + /// + internal void ResetSocketTypes() + { + foreach (SocketVisual socket in GetSockets()) + { + if (socketTypeInfo.ContainsKey(socket.Name)) + { + socketTypeInfo[socket.Name].RuntimeType = socketTypeInfo[socket.Name].StaticType; + } + } + DiscardCache(); + } + + /// + /// Checks if this node has dynamic type support + /// + internal bool HasDynamicTypeSupport() + { + return TypePropagation.IsDynamicNode(this); + } } } diff --git a/NodeEditor/NodesControl.cs b/NodeEditor/NodesControl.cs index 8ec33eb..7473580 100644 --- a/NodeEditor/NodesControl.cs +++ b/NodeEditor/NodesControl.cs @@ -288,7 +288,7 @@ private void NodesControl_MouseDown(object sender, MouseEventArgs e) { if ((ModifierKeys & Keys.Control) == Keys.Control) { - var connection = + NodeConnection connection = graph.Connections.FirstOrDefault( x => x.InputNode == nodeWhole && x.InputSocketName == socket.Name); @@ -316,6 +316,13 @@ private void NodesControl_MouseDown(object sender, MouseEventArgs e) graph.Connections.Remove(connection); rebuildConnectionDictionary = true; + + // Handle type propagation after disconnection + if (connection != null && connection.InputNode.HasDynamicTypeSupport()) + { + connection.InputNode.PropagateTypes(graph); + PropagateTypesDownstream(connection.InputNode); + } } else { @@ -344,24 +351,30 @@ private void NodesControl_MouseDown(object sender, MouseEventArgs e) private bool IsConnectable(SocketVisual a, SocketVisual b) { - var input = a.Input ? a : b; - var output = a.Input ? b : a; - var otype = Type.GetType(output.Type.FullName.Replace("&", ""), AssemblyResolver, TypeResolver); - var itype = Type.GetType(input.Type.FullName.Replace("&", ""), AssemblyResolver, TypeResolver); - if (otype == null || itype == null) return false; + SocketVisual input = a.Input ? a : b; + SocketVisual output = a.Input ? b : a; + + // Use runtime types if available, otherwise fall back to static types + Type outputType = output.RuntimeType ?? output.Type; + Type inputType = input.RuntimeType ?? input.Type; + + outputType = Type.GetType(outputType.FullName.Replace("&", ""), AssemblyResolver, TypeResolver); + inputType = Type.GetType(inputType.FullName.Replace("&", ""), AssemblyResolver, TypeResolver); + + if (outputType == null || inputType == null) return false; // Check for exact match - if (otype == itype) return true; + if (outputType == inputType) return true; // Check for inheritance - if (otype.IsSubclassOf(itype)) return true; + if (outputType.IsSubclassOf(inputType)) return true; // Check for interface implementation - if (itype.IsInterface && itype.IsAssignableFrom(otype)) return true; + if (inputType.IsInterface && inputType.IsAssignableFrom(outputType)) return true; // Special case: Check if output type can be assigned to input type // This handles cases like string[] to IEnumerable - if (itype.IsAssignableFrom(otype)) return true; + if (inputType.IsAssignableFrom(outputType)) return true; return false; } @@ -406,7 +419,7 @@ private void NodesControl_MouseUp(object sender, MouseEventArgs e) { if (IsConnectable(dragSocket, socket) && dragSocket.Input != socket.Input) { - var nc = new NodeConnection(); + NodeConnection nc = new NodeConnection(); if (!dragSocket.Input) { nc.OutputNode = dragSocketNode; @@ -427,6 +440,9 @@ private void NodesControl_MouseUp(object sender, MouseEventArgs e) graph.Connections.Add(nc); rebuildConnectionDictionary = true; + + // Propagate types for dynamic nodes + PropagateTypesForConnection(nc); } } } @@ -437,6 +453,224 @@ private void NodesControl_MouseUp(object sender, MouseEventArgs e) needRepaint = true; } + /// + /// Propagates types through the graph when a connection is made or removed + /// + private void PropagateTypesForConnection(NodeConnection connection) + { + if (connection == null) return; + + // Propagate types for the input node if it supports dynamic typing + if (connection.InputNode.HasDynamicTypeSupport()) + { + connection.InputNode.PropagateTypes(graph); + + // Check for incompatible downstream connections + DynamicNodeAttribute nodeAttr = connection.InputNode.Type?.GetCustomAttribute(); + if (nodeAttr != null && nodeAttr.AutoDisconnectIncompatible) + { + DisconnectIncompatibleConnections(connection.InputNode); + } + } + + // Propagate types through all downstream nodes + PropagateTypesDownstream(connection.InputNode); + } + + /// + /// Propagates types to all nodes downstream from the given node + /// + private void PropagateTypesDownstream(NodeVisual startNode) + { + HashSet visited = new HashSet(); + Queue toProcess = new Queue(); + toProcess.Enqueue(startNode); + + while (toProcess.Count > 0) + { + NodeVisual current = toProcess.Dequeue(); + if (visited.Contains(current)) continue; + visited.Add(current); + + // Find all nodes connected to outputs of current node + List outputConnections = graph.Connections + .Where(c => c.OutputNode == current) + .ToList(); + + foreach (NodeConnection conn in outputConnections) + { + if (conn.InputNode.HasDynamicTypeSupport()) + { + conn.InputNode.PropagateTypes(graph); + + // Check for incompatible connections + DynamicNodeAttribute nodeAttr = conn.InputNode.Type?.GetCustomAttribute(); + if (nodeAttr != null && nodeAttr.AutoDisconnectIncompatible) + { + DisconnectIncompatibleConnections(conn.InputNode); + } + } + + if (!visited.Contains(conn.InputNode)) + { + toProcess.Enqueue(conn.InputNode); + } + } + } + } + + /// + /// Disconnects connections that are no longer type-compatible + /// + private void DisconnectIncompatibleConnections(NodeVisual node) + { + List connectionsToRemove = new List(); + + // Check input connections + List inputConnections = graph.Connections + .Where(c => c.InputNode == node) + .ToList(); + + foreach (NodeConnection conn in inputConnections) + { + Type expectedType = node.GetSocketRuntimeType(conn.InputSocketName); + Type actualType = conn.OutputNode.GetSocketRuntimeType(conn.OutputSocketName); + + if (expectedType != null && actualType != null) + { + if (!TypePropagation.AreTypesCompatible(actualType, expectedType)) + { + connectionsToRemove.Add(conn); + } + } + } + + // Check output connections + List outputConnections = graph.Connections + .Where(c => c.OutputNode == node) + .ToList(); + + foreach (NodeConnection conn in outputConnections) + { + Type providedType = node.GetSocketRuntimeType(conn.OutputSocketName); + Type requiredType = conn.InputNode.GetSocketRuntimeType(conn.InputSocketName); + + if (providedType != null && requiredType != null) + { + // Disconnect if types are incompatible + if (!TypePropagation.AreTypesCompatible(providedType, requiredType)) + { + // Special case: if the provided type became generic (object) and the required type is specific, + // we should disconnect - this handles the case where dynamic node reverts to object + if (providedType == typeof(object) && requiredType != typeof(object)) + { + connectionsToRemove.Add(conn); + } + // For other incompatibilities, only disconnect if the input socket is dynamic + else if (TypePropagation.IsDynamicSocket(conn.InputNode, conn.InputSocketName)) + { + connectionsToRemove.Add(conn); + } + // Also disconnect if both sockets are from dynamic nodes + else if (TypePropagation.IsDynamicSocket(node, conn.OutputSocketName)) + { + connectionsToRemove.Add(conn); + } + } + } + } + + // Remove incompatible connections + foreach (NodeConnection conn in connectionsToRemove) + { + graph.Connections.Remove(conn); + rebuildConnectionDictionary = true; + + // Reset types for disconnected input node if it's dynamic + if (conn.InputNode.HasDynamicTypeSupport()) + { + conn.InputNode.PropagateTypes(graph); + PropagateTypesDownstream(conn.InputNode); + } + } + } + + /// + /// Disconnects connections that are no longer type-compatible (overload that returns removed connections) + /// + private void DisconnectIncompatibleConnections(NodeVisual node, List connectionsRemoved) + { + List connectionsToRemove = new List(); + + // Check input connections + List inputConnections = graph.Connections + .Where(c => c.InputNode == node) + .ToList(); + + foreach (NodeConnection conn in inputConnections) + { + Type expectedType = node.GetSocketRuntimeType(conn.InputSocketName); + Type actualType = conn.OutputNode.GetSocketRuntimeType(conn.OutputSocketName); + + if (expectedType != null && actualType != null) + { + if (!TypePropagation.AreTypesCompatible(actualType, expectedType)) + { + connectionsToRemove.Add(conn); + } + } + } + + // Check output connections + List outputConnections = graph.Connections + .Where(c => c.OutputNode == node) + .ToList(); + + foreach (NodeConnection conn in outputConnections) + { + Type providedType = node.GetSocketRuntimeType(conn.OutputSocketName); + Type requiredType = conn.InputNode.GetSocketRuntimeType(conn.InputSocketName); + + if (providedType != null && requiredType != null) + { + // Disconnect if types are incompatible + if (!TypePropagation.AreTypesCompatible(providedType, requiredType)) + { + // Special case: if the provided type became generic (object) and the required type is specific, + // we should disconnect - this handles the case where dynamic node reverts to object + if (providedType == typeof(object) && requiredType != typeof(object)) + { + connectionsToRemove.Add(conn); + } + // For other incompatibilities, only disconnect if the input socket is dynamic + else if (TypePropagation.IsDynamicSocket(conn.InputNode, conn.InputSocketName)) + { + connectionsToRemove.Add(conn); + } + // Also disconnect if both sockets are from dynamic nodes + else if (TypePropagation.IsDynamicSocket(node, conn.OutputSocketName)) + { + connectionsToRemove.Add(conn); + } + } + } + } + + // Remove incompatible connections + foreach (NodeConnection conn in connectionsToRemove) + { + graph.Connections.Remove(conn); + rebuildConnectionDictionary = true; + connectionsRemoved.Add(conn); + + // Reset types for disconnected input node if it's dynamic + if (conn.InputNode.HasDynamicTypeSupport()) + { + conn.InputNode.PropagateTypes(graph); + } + } + } + private void AddToMenu(ToolStripItemCollection items, NodeToken token, string path, EventHandler click) { var pathParts = path.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); @@ -632,16 +866,120 @@ private void DeleteSelectedNodes() { if (graph.Nodes.Exists(x => x.IsSelected)) { - foreach (var n in graph.Nodes.Where(x => x.IsSelected)) + // Collect all nodes that will be affected by the deletion BEFORE removing connections + HashSet affectedNodes = new HashSet(); + + foreach (NodeVisual selectedNode in graph.Nodes.Where(x => x.IsSelected)) + { + // Find all downstream nodes from this node before we delete connections + CollectDownstreamNodes(selectedNode, affectedNodes); + + // Also collect nodes that have this node as input + List incomingConnections = graph.Connections + .Where(x => x.OutputNode == selectedNode) + .ToList(); + + foreach (NodeConnection conn in incomingConnections) + { + if (conn.InputNode.HasDynamicTypeSupport() && !graph.Nodes.Any(n => n.IsSelected && n == conn.InputNode)) + { + affectedNodes.Add(conn.InputNode); + } + } + } + + // Now remove the selected nodes and their connections + foreach (NodeVisual selectedNode in graph.Nodes.Where(x => x.IsSelected)) { - Controls.Remove(n.CustomEditor); - graph.Connections.RemoveAll( - x => x.OutputNode == n || x.InputNode == n); + Controls.Remove(selectedNode.CustomEditor); + graph.Connections.RemoveAll(x => x.OutputNode == selectedNode || x.InputNode == selectedNode); } + graph.Nodes.RemoveAll(x => graph.Nodes.Where(n => n.IsSelected).Contains(x)); + rebuildConnectionDictionary = true; + + // After deletion, update types for all affected nodes + // We need to do this in multiple passes because disconnecting connections might affect more nodes + HashSet processedNodes = new HashSet(); + Queue nodesToProcess = new Queue(); + + // Initial set of affected nodes + foreach (NodeVisual affectedNode in affectedNodes) + { + if (graph.Nodes.Contains(affectedNode)) + { + nodesToProcess.Enqueue(affectedNode); + } + } + + while (nodesToProcess.Count > 0) + { + NodeVisual currentNode = nodesToProcess.Dequeue(); + if (processedNodes.Contains(currentNode) || !graph.Nodes.Contains(currentNode)) + continue; + + processedNodes.Add(currentNode); + + // Store connections before type propagation to see what changes + List connectionsBefore = graph.Connections + .Where(c => c.OutputNode == currentNode) + .ToList(); + + // Propagate types + currentNode.PropagateTypes(graph); + + // Check for incompatible connections after type reset + DynamicNodeAttribute nodeAttr = currentNode.Type?.GetCustomAttribute(); + if (nodeAttr != null && nodeAttr.AutoDisconnectIncompatible) + { + List connectionsRemoved = new List(); + DisconnectIncompatibleConnections(currentNode, connectionsRemoved); + + // If connections were removed, add affected downstream nodes to processing queue + foreach (NodeConnection removedConn in connectionsRemoved) + { + if (!processedNodes.Contains(removedConn.InputNode)) + { + nodesToProcess.Enqueue(removedConn.InputNode); + } + } + } + + // Add directly connected downstream nodes + List outputConnections = graph.Connections + .Where(c => c.OutputNode == currentNode) + .ToList(); + + foreach (NodeConnection conn in outputConnections) + { + if (!processedNodes.Contains(conn.InputNode)) + { + nodesToProcess.Enqueue(conn.InputNode); + } + } + } } Invalidate(); } + + /// + /// Collects all nodes downstream from the given node + /// + private void CollectDownstreamNodes(NodeVisual startNode, HashSet collected) + { + List outgoingConnections = graph.Connections + .Where(c => c.OutputNode == startNode) + .ToList(); + + foreach (NodeConnection conn in outgoingConnections) + { + if (!collected.Contains(conn.InputNode) && !graph.Nodes.Any(n => n.IsSelected && n == conn.InputNode)) + { + collected.Add(conn.InputNode); + CollectDownstreamNodes(conn.InputNode, collected); // Recursive call to get all downstream + } + } + } /// /// Executes whole node graph (when called parameterless) or given node when specified. @@ -1034,6 +1372,16 @@ public void Deserialize(byte[] data) } br.ReadBytes(br.ReadInt32()); //read additional data } + + // Propagate types for all dynamic nodes after loading + foreach (NodeVisual node in graph.Nodes) + { + if (node.HasDynamicTypeSupport()) + { + node.PropagateTypes(graph); + } + } + Refresh(); } @@ -1270,6 +1618,15 @@ public void DeserializeFromJson(string json) graph.Connections.Add(connection); } } + + // Propagate types for all dynamic nodes after loading + foreach (NodeVisual node in graph.Nodes) + { + if (node.HasDynamicTypeSupport()) + { + node.PropagateTypes(graph); + } + } rebuildConnectionDictionary = true; Refresh(); diff --git a/NodeEditor/SocketVisual.cs b/NodeEditor/SocketVisual.cs index f0f8d35..25ea70c 100644 --- a/NodeEditor/SocketVisual.cs +++ b/NodeEditor/SocketVisual.cs @@ -38,6 +38,11 @@ internal class SocketVisual public bool Input { get; set; } public object Value { get; set; } public bool IsMainExecution { get; set; } + + /// + /// Runtime type for dynamic sockets (may differ from static Type) + /// + public Type RuntimeType { get; set; } public bool IsExecution { @@ -46,9 +51,9 @@ public bool IsExecution public void Draw(Graphics g, Point mouseLocation, MouseButtons mouseButtons) { - var socketRect = new RectangleF(X, Y, Width, Height); - var hover = socketRect.Contains(mouseLocation); - var fontBrush = Brushes.Black; + RectangleF socketRect = new RectangleF(X, Y, Width, Height); + bool hover = socketRect.Contains(mouseLocation); + Brush fontBrush = Brushes.Black; if (hover) { @@ -61,14 +66,14 @@ public void Draw(Graphics g, Point mouseLocation, MouseButtons mouseButtons) if (Input) { - var sf = new StringFormat(); + StringFormat sf = new StringFormat(); sf.Alignment = StringAlignment.Near; sf.LineAlignment = StringAlignment.Center; g.DrawString(Name,SystemFonts.SmallCaptionFont, fontBrush, new RectangleF(X+Width+2,Y,1000,Height), sf); } else { - var sf = new StringFormat(); + StringFormat sf = new StringFormat(); sf.Alignment = StringAlignment.Far; sf.LineAlignment = StringAlignment.Center; g.DrawString(Name, SystemFonts.SmallCaptionFont, fontBrush, new RectangleF(X-1000, Y, 1000, Height), sf); @@ -82,9 +87,155 @@ public void Draw(Graphics g, Point mouseLocation, MouseButtons mouseButtons) g.DrawImage(Resources.exec, socketRect); } else + { + // Draw socket with type-based color + DrawColoredSocket(g, socketRect); + } + } + + private void DrawColoredSocket(Graphics g, RectangleF socketRect) + { + // Get the effective type (runtime type if available, otherwise static type) + Type effectiveType = RuntimeType ?? Type; + if (effectiveType == null) { g.DrawImage(Resources.socket, socketRect); + return; + } + + // Handle ref/out types first before checking collection type + if (effectiveType.IsByRef) + { + effectiveType = effectiveType.GetElementType(); + } + + // Check if it's a collection type + bool isCollection = IsCollectionType(effectiveType); + + // Generate color from type (get base element type for collections) + Color typeColor = GetTypeColor(effectiveType, isCollection); + + // Create a colored version of the socket + using (Bitmap coloredSocket = new Bitmap((int)socketRect.Width, (int)socketRect.Height)) + { + using (Graphics tempG = Graphics.FromImage(coloredSocket)) + { + tempG.SmoothingMode = SmoothingMode.AntiAlias; + + // Draw colored shape - square for collections, circle for single values + using (Brush brush = new SolidBrush(typeColor)) + { + if (isCollection) + { + // Draw a rounded square for collections + tempG.FillRectangle(brush, 1, 1, coloredSocket.Width - 3, coloredSocket.Height - 3); + } + else + { + // Draw a circle for single values + tempG.FillEllipse(brush, 0, 0, coloredSocket.Width - 1, coloredSocket.Height - 1); + } + } + + // Draw a border + using (Pen pen = new Pen(Color.FromArgb(100, Color.Black), 1)) + { + if (isCollection) + { + tempG.DrawRectangle(pen, 1, 1, coloredSocket.Width - 3, coloredSocket.Height - 3); + } + else + { + tempG.DrawEllipse(pen, 0, 0, coloredSocket.Width - 1, coloredSocket.Height - 1); + } + } + } + + g.DrawImage(coloredSocket, socketRect); + } + } + + private bool IsCollectionType(Type type) + { + if (type.IsArray) return true; + if (type == typeof(System.Collections.IEnumerable)) return true; + + if (type.IsGenericType) + { + Type genericDef = type.GetGenericTypeDefinition(); + return genericDef == typeof(IEnumerable<>) || + genericDef == typeof(List<>) || + genericDef == typeof(IList<>) || + genericDef == typeof(ICollection<>); } + + return typeof(System.Collections.IEnumerable).IsAssignableFrom(type) && type != typeof(string); + } + + private Color GetTypeColor(Type type, bool isCollection = false) + { + // For collections, get the element type color + if (isCollection && type.IsGenericType) + { + Type genericDef = type.GetGenericTypeDefinition(); + if (genericDef == typeof(IEnumerable<>) || genericDef == typeof(List<>) || genericDef == typeof(IList<>) || genericDef == typeof(ICollection<>)) + { + Type elementType = type.GetGenericArguments()[0]; + return GetTypeColor(elementType, false); // Get base element color + } + } + + // For arrays, get the element type color + if (isCollection && type.IsArray) + { + Type elementType = type.GetElementType(); + return GetTypeColor(elementType, false); + } + + // Special colors for common types + if (type == typeof(string)) + return Color.FromArgb(255, 184, 107); // Orange + if (type == typeof(int) || type == typeof(float) || type == typeof(double) || type == typeof(decimal)) + return Color.FromArgb(107, 203, 119); // Green + if (type == typeof(bool)) + return Color.FromArgb(255, 133, 133); // Red + if (type == typeof(ExecutionPath)) + return Color.White; + if (type == typeof(object)) + return Color.FromArgb(200, 200, 200); // Gray for generic object + + // For custom types, generate color from hash + int hash = type.FullName.GetHashCode(); + + // Use hash bits directly to generate HSL values + // This gives us deterministic colors without Random + float hue = (hash & 0xFFFF) / 65535f * 360f; // Lower 16 bits for hue + float saturation = 0.6f + ((hash >> 16) & 0xFF) / 255f * 0.3f; // Next 8 bits for saturation (60-90%) + float lightness = 0.5f + ((hash >> 24) & 0xFF) / 255f * 0.2f; // Next 8 bits for lightness (50-70%) + + return ColorFromHSL(hue, saturation, lightness); + } + + private Color ColorFromHSL(float hue, float saturation, float lightness) + { + float c = (1 - Math.Abs(2 * lightness - 1)) * saturation; + float x = c * (1 - Math.Abs((hue / 60) % 2 - 1)); + float m = lightness - c / 2; + + float r = 0, g = 0, b = 0; + + if (hue < 60) { r = c; g = x; b = 0; } + else if (hue < 120) { r = x; g = c; b = 0; } + else if (hue < 180) { r = 0; g = c; b = x; } + else if (hue < 240) { r = 0; g = x; b = c; } + else if (hue < 300) { r = x; g = 0; b = c; } + else { r = c; g = 0; b = x; } + + return Color.FromArgb( + (int)((r + m) * 255), + (int)((g + m) * 255), + (int)((b + m) * 255) + ); } public RectangleF GetBounds() diff --git a/NodeEditor/TypePropagation.cs b/NodeEditor/TypePropagation.cs new file mode 100644 index 0000000..dee2c77 --- /dev/null +++ b/NodeEditor/TypePropagation.cs @@ -0,0 +1,388 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +namespace NodeEditor +{ + /// + /// Handles dynamic type propagation for generic nodes in the node editor + /// + internal static class TypePropagation + { + /// + /// Gets parameter info with dynamic type attributes for a node + /// + public static Dictionary GetDynamicParameters(NodeVisual node) + { + Dictionary result = new Dictionary(); + + if (node?.Type == null) return result; + + foreach (ParameterInfo param in node.Type.GetParameters()) + { + DynamicTypeAttribute dynamicAttr = param.GetCustomAttribute(); + if (dynamicAttr != null) + { + result[param.Name] = dynamicAttr; + } + } + + return result; + } + + /// + /// Checks if a node supports dynamic typing + /// + public static bool IsDynamicNode(NodeVisual node) + { + if (node?.Type == null) return false; + return node.Type.GetCustomAttribute() != null; + } + + /// + /// Propagates types through a node based on its dynamic type attributes + /// + public static void PropagateNodeTypes(NodeVisual node, NodesGraph graph) + { + if (!IsDynamicNode(node)) return; + + Dictionary dynamicParams = GetDynamicParameters(node); + if (dynamicParams.Count == 0) return; + + // Group parameters by TypeGroup + Dictionary typeGroups = new Dictionary(); + + // First pass: determine types from inputs + foreach (KeyValuePair param in dynamicParams) + { + NodeConnection connection = graph.Connections.FirstOrDefault( + c => c.InputNode == node && c.InputSocketName == param.Key); + + if (connection != null) + { + Type inferredType = GetActualType(connection); + if (inferredType == null) + inferredType = connection.OutputSocket.Type; + + // Store the inferred type for this parameter's group + if (!string.IsNullOrEmpty(param.Value.TypeGroup)) + { + typeGroups[param.Value.TypeGroup] = inferredType; + } + } + } + + // Second pass: apply types to all parameters based on attributes + foreach (KeyValuePair param in dynamicParams) + { + Type targetType = null; + + // If this parameter derives from another + if (!string.IsNullOrEmpty(param.Value.DerivedFrom)) + { + // First check if it's deriving from a type group output + if (!string.IsNullOrEmpty(param.Value.TypeGroup) && typeGroups.ContainsKey(param.Value.TypeGroup)) + { + // Use the type group value + targetType = typeGroups[param.Value.TypeGroup]; + + if (param.Value.ExtractElementType) + { + targetType = GetElementType(targetType); + } + else if (param.Value.WrapInCollection) + { + targetType = CreateTypedCollectionType(targetType, typeof(List)); + } + } + else + { + // Find the source parameter's type + NodeConnection sourceConnection = graph.Connections.FirstOrDefault( + c => c.InputNode == node && c.InputSocketName == param.Value.DerivedFrom); + + if (sourceConnection != null) + { + targetType = GetActualType(sourceConnection) ?? sourceConnection.OutputSocket.Type; + + if (param.Value.ExtractElementType) + { + targetType = GetElementType(targetType); + } + else if (param.Value.WrapInCollection) + { + targetType = CreateTypedCollectionType(targetType, typeof(List)); + } + } + else + { + // No connection found for the source parameter, use default type + ParameterInfo paramInfo = node.Type.GetParameters().FirstOrDefault(p => p.Name == param.Key); + if (paramInfo != null) + { + targetType = paramInfo.ParameterType; + if (targetType.IsByRef) + targetType = targetType.GetElementType(); + } + } + } + } + // Otherwise use the type group + else if (!string.IsNullOrEmpty(param.Value.TypeGroup) && typeGroups.ContainsKey(param.Value.TypeGroup)) + { + targetType = typeGroups[param.Value.TypeGroup]; + + if (param.Value.ExtractElementType) + { + targetType = GetElementType(targetType); + } + else if (param.Value.WrapInCollection) + { + targetType = CreateTypedCollectionType(targetType, typeof(List)); + } + } + else if (!string.IsNullOrEmpty(param.Value.TypeGroup)) + { + // Type group exists but no type found, reset to default + ParameterInfo paramInfo = node.Type.GetParameters().FirstOrDefault(p => p.Name == param.Key); + if (paramInfo != null) + { + targetType = paramInfo.ParameterType; + if (targetType.IsByRef) + targetType = targetType.GetElementType(); + } + } + + // Update the socket's runtime type + if (targetType != null) + { + node.UpdateSocketType(param.Key, targetType); + } + } + } + + /// + /// Checks if a socket has dynamic type support + /// + public static bool IsDynamicSocket(NodeVisual node, string socketName) + { + if (node?.Type == null) return false; + + ParameterInfo param = node.Type.GetParameters().FirstOrDefault(p => p.Name == socketName); + if (param == null) return false; + + return param.GetCustomAttribute() != null; + } + + /// + /// Checks if a socket is generic (accepts/outputs object or IEnumerable of object) + /// + public static bool IsGenericSocket(SocketVisual socket) + { + if (socket == null || socket.Type == null) return false; + + Type type = socket.Type; + + // Handle ref/out parameters + if (type.IsByRef) + { + type = type.GetElementType(); + } + + // Check for object type + if (type == typeof(object)) + { + return true; + } + + // Check for IEnumerable + if (type.IsGenericType) + { + Type genericDef = type.GetGenericTypeDefinition(); + Type[] genericArgs = type.GetGenericArguments(); + + if ((genericDef == typeof(IEnumerable<>) || + genericDef == typeof(IList<>) || + genericDef == typeof(List<>)) && + genericArgs.Length == 1 && + genericArgs[0] == typeof(object)) + { + return true; + } + } + + // Check for non-generic IEnumerable + if (type == typeof(IEnumerable)) + { + return true; + } + + // Check for object array + if (type.IsArray && type.GetElementType() == typeof(object)) + { + return true; + } + + return false; + } + + /// + /// Gets the actual runtime type from a connection's source + /// + public static Type GetActualType(NodeConnection connection) + { + if (connection == null || connection.OutputSocket == null) + return null; + + DynamicNodeContext outputContext = connection.OutputNode.GetNodeContext(); + object value = outputContext[connection.OutputSocketName]; + + if (value != null) + { + return value.GetType(); + } + + return connection.OutputSocket.Type; + } + + /// + /// Infers the element type from a collection type + /// + public static Type GetElementType(Type collectionType) + { + if (collectionType == null) return typeof(object); + + // Handle arrays + if (collectionType.IsArray) + { + return collectionType.GetElementType(); + } + + // Handle generic collections + if (collectionType.IsGenericType) + { + Type genericDef = collectionType.GetGenericTypeDefinition(); + Type[] genericArgs = collectionType.GetGenericArguments(); + + if ((genericDef == typeof(IEnumerable<>) || + genericDef == typeof(IList<>) || + genericDef == typeof(List<>) || + genericDef == typeof(ICollection<>)) && + genericArgs.Length == 1) + { + return genericArgs[0]; + } + } + + // Try to find IEnumerable in interfaces + foreach (Type interfaceType in collectionType.GetInterfaces()) + { + if (interfaceType.IsGenericType && + interfaceType.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + return interfaceType.GetGenericArguments()[0]; + } + } + + return typeof(object); + } + + /// + /// Creates a properly typed collection for a given element type + /// + public static Type CreateTypedCollectionType(Type elementType, Type templateType) + { + if (elementType == null) elementType = typeof(object); + + // If template is an array + if (templateType.IsArray) + { + return elementType.MakeArrayType(); + } + + // If template is a generic type + if (templateType.IsGenericType) + { + Type genericDef = templateType.GetGenericTypeDefinition(); + + if (genericDef == typeof(IEnumerable<>) || + genericDef == typeof(IList<>) || + genericDef == typeof(List<>) || + genericDef == typeof(ICollection<>)) + { + return genericDef.MakeGenericType(elementType); + } + } + + // Default to List + return typeof(List<>).MakeGenericType(elementType); + } + + /// + /// Checks if two types are compatible for connection + /// + public static bool AreTypesCompatible(Type sourceType, Type targetType) + { + if (sourceType == null || targetType == null) return false; + + // Handle ref/out parameters + if (sourceType.IsByRef) sourceType = sourceType.GetElementType(); + if (targetType.IsByRef) targetType = targetType.GetElementType(); + + // Exact match + if (sourceType == targetType) return true; + + // Check if target can accept source (inheritance/interface) + if (targetType.IsAssignableFrom(sourceType)) return true; + + // Special handling for collections + if (IsCollectionType(sourceType) && IsCollectionType(targetType)) + { + Type sourceElement = GetElementType(sourceType); + Type targetElement = GetElementType(targetType); + + // Allow connection if element types are compatible + return targetElement == typeof(object) || + targetElement.IsAssignableFrom(sourceElement); + } + + return false; + } + + private static bool IsCollectionType(Type type) + { + if (type.IsArray) return true; + if (type == typeof(IEnumerable)) return true; + + if (type.IsGenericType) + { + Type genericDef = type.GetGenericTypeDefinition(); + return genericDef == typeof(IEnumerable<>) || + genericDef == typeof(IList<>) || + genericDef == typeof(List<>) || + genericDef == typeof(ICollection<>); + } + + return typeof(IEnumerable).IsAssignableFrom(type); + } + } + + /// + /// Stores runtime type information for a socket + /// + internal class SocketTypeInfo + { + public Type StaticType { get; set; } // The type declared in code + public Type RuntimeType { get; set; } // The actual type at runtime + public bool IsGeneric { get; set; } // Whether this socket accepts generic types + + public SocketTypeInfo(Type staticType) + { + StaticType = staticType; + RuntimeType = staticType; + IsGeneric = TypePropagation.IsGenericSocket(new SocketVisual { Type = staticType }); + } + } +} \ No newline at end of file From 298e7cc75e5045bce4140aa5872edd238f3fb22c Mon Sep 17 00:00:00 2001 From: Brian Whitenack Date: Sat, 13 Sep 2025 20:34:48 -0500 Subject: [PATCH 4/8] feat: Dragging, Better Type Propegation --- .claude/settings.local.json | 7 +- MathSample/FormMathSample.Designer.cs | 107 ++- MathSample/FormMathSample.cs | 51 +- MathSample/MathSample.csproj | 4 + MathSample/PartCalculation.cs | 127 +++- MathSample/packages.config | 4 + NodeEditor/FlowControls/ForeachFlowControl.cs | 53 +- NodeEditor/FlowControls/IfElseFlowControl.cs | 59 ++ NodeEditor/INodesContext.cs | 12 +- NodeEditor/NodeEditor.csproj | 1 + NodeEditor/NodeVisual.cs | 28 +- NodeEditor/NodesControl.cs | 627 +++++++++++++----- NodeEditor/NodesGraph.cs | 82 +++ NodeEditor/TypePropagation.cs | 311 ++++++++- SampleCommon/ControlNodeEditor.Designer.cs | 35 +- 15 files changed, 1198 insertions(+), 310 deletions(-) create mode 100644 MathSample/packages.config create mode 100644 NodeEditor/FlowControls/IfElseFlowControl.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0b49ed9..b082b02 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -9,7 +9,12 @@ "Bash(dotnet build)", "Bash(msbuild:*)", "Bash(dotnet build:*)", - "Bash(MSBuild.exe:*)" + "Bash(MSBuild.exe:*)", + "Bash(\"C:\\Program Files (x86)\\Microsoft Visual Studio\\2019\\Professional\\MSBuild\\Current\\Bin\\MSBuild.exe\" NodeEditorNodeEditor.csproj)", + "Bash(where msbuild)", + "Bash(\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional\\MSBuild\\Current\\Bin\\amd64\\MSBuild.exe\" NodeEditorNodeEditor.csproj)", + "Bash(\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional\\MSBuild\\Current\\Bin\\amd64\\MSBuild.exe\" \"NodeEditor\\NodeEditor.csproj\")", + "Bash(\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional\\MSBuild\\Current\\Bin\\amd64\\MSBuild.exe\" NodeEditorWinforms.sln)" ], "deny": [], "ask": [] diff --git a/MathSample/FormMathSample.Designer.cs b/MathSample/FormMathSample.Designer.cs index efaab50..fedb69b 100644 --- a/MathSample/FormMathSample.Designer.cs +++ b/MathSample/FormMathSample.Designer.cs @@ -33,7 +33,20 @@ private void InitializeComponent() this.btnSave = new System.Windows.Forms.ToolStripButton(); this.btnLoad = new System.Windows.Forms.ToolStripButton(); this.controlNodeEditor = new SampleCommon.ControlNodeEditor(); + this.splitContainer1 = new System.Windows.Forms.SplitContainer(); + this.tabControl1 = new System.Windows.Forms.TabControl(); + this.tabMeasurements = new System.Windows.Forms.TabPage(); + this.tabParts = new System.Windows.Forms.TabPage(); + this.txtMeasurements = new System.Windows.Forms.TextBox(); + this.txtParts = new System.Windows.Forms.TextBox(); this.toolStrip1.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).BeginInit(); + this.splitContainer1.Panel1.SuspendLayout(); + this.splitContainer1.Panel2.SuspendLayout(); + this.splitContainer1.SuspendLayout(); + this.tabControl1.SuspendLayout(); + this.tabMeasurements.SuspendLayout(); + this.tabParts.SuspendLayout(); this.SuspendLayout(); // // toolStrip1 @@ -69,26 +82,102 @@ private void InitializeComponent() // // controlNodeEditor // - this.controlNodeEditor.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) - | System.Windows.Forms.AnchorStyles.Left) - | System.Windows.Forms.AnchorStyles.Right))); - this.controlNodeEditor.Location = new System.Drawing.Point(0, 28); + this.controlNodeEditor.Dock = System.Windows.Forms.DockStyle.Fill; + this.controlNodeEditor.Location = new System.Drawing.Point(0, 0); this.controlNodeEditor.Name = "controlNodeEditor"; - this.controlNodeEditor.Size = new System.Drawing.Size(957, 482); + this.controlNodeEditor.Size = new System.Drawing.Size(805, 485); this.controlNodeEditor.TabIndex = 0; // + // splitContainer1 + // + this.splitContainer1.Dock = System.Windows.Forms.DockStyle.Fill; + this.splitContainer1.Location = new System.Drawing.Point(0, 25); + this.splitContainer1.Name = "splitContainer1"; + // + // splitContainer1.Panel1 + // + this.splitContainer1.Panel1.Controls.Add(this.tabControl1); + // + // splitContainer1.Panel2 + // + this.splitContainer1.Panel2.Controls.Add(this.controlNodeEditor); + this.splitContainer1.Size = new System.Drawing.Size(957, 485); + this.splitContainer1.SplitterDistance = 148; + this.splitContainer1.TabIndex = 2; + // + // tabControl1 + // + this.tabControl1.Controls.Add(this.tabMeasurements); + this.tabControl1.Controls.Add(this.tabParts); + this.tabControl1.Dock = System.Windows.Forms.DockStyle.Fill; + this.tabControl1.Location = new System.Drawing.Point(0, 0); + this.tabControl1.Name = "tabControl1"; + this.tabControl1.SelectedIndex = 0; + this.tabControl1.Size = new System.Drawing.Size(148, 485); + this.tabControl1.TabIndex = 0; + // + // tabMeasurements + // + this.tabMeasurements.Controls.Add(this.txtMeasurements); + this.tabMeasurements.Location = new System.Drawing.Point(4, 22); + this.tabMeasurements.Name = "tabMeasurements"; + this.tabMeasurements.Padding = new System.Windows.Forms.Padding(3); + this.tabMeasurements.Size = new System.Drawing.Size(140, 459); + this.tabMeasurements.TabIndex = 0; + this.tabMeasurements.Text = "Measurements"; + this.tabMeasurements.UseVisualStyleBackColor = true; + // + // tabParts + // + this.tabParts.Controls.Add(this.txtParts); + this.tabParts.Location = new System.Drawing.Point(4, 22); + this.tabParts.Name = "tabParts"; + this.tabParts.Padding = new System.Windows.Forms.Padding(3); + this.tabParts.Size = new System.Drawing.Size(311, 459); + this.tabParts.TabIndex = 1; + this.tabParts.Text = "Parts"; + this.tabParts.UseVisualStyleBackColor = true; + // + // txtMeasurements + // + this.txtMeasurements.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.txtMeasurements.Dock = System.Windows.Forms.DockStyle.Fill; + this.txtMeasurements.Location = new System.Drawing.Point(3, 3); + this.txtMeasurements.Multiline = true; + this.txtMeasurements.Name = "txtMeasurements"; + this.txtMeasurements.Size = new System.Drawing.Size(134, 453); + this.txtMeasurements.TabIndex = 0; + // + // txtParts + // + this.txtParts.Dock = System.Windows.Forms.DockStyle.Fill; + this.txtParts.Location = new System.Drawing.Point(3, 3); + this.txtParts.Multiline = true; + this.txtParts.Name = "txtParts"; + this.txtParts.Size = new System.Drawing.Size(305, 453); + this.txtParts.TabIndex = 0; + // // FormMathSample // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.ClientSize = new System.Drawing.Size(957, 510); + this.Controls.Add(this.splitContainer1); this.Controls.Add(this.toolStrip1); - this.Controls.Add(this.controlNodeEditor); this.Name = "FormMathSample"; this.Text = "NodeEditor WinForms - Math Sample"; this.Load += new System.EventHandler(this.FormMathSample_Load); this.toolStrip1.ResumeLayout(false); this.toolStrip1.PerformLayout(); + this.splitContainer1.Panel1.ResumeLayout(false); + this.splitContainer1.Panel2.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).EndInit(); + this.splitContainer1.ResumeLayout(false); + this.tabControl1.ResumeLayout(false); + this.tabMeasurements.ResumeLayout(false); + this.tabMeasurements.PerformLayout(); + this.tabParts.ResumeLayout(false); + this.tabParts.PerformLayout(); this.ResumeLayout(false); this.PerformLayout(); @@ -100,6 +189,12 @@ private void InitializeComponent() private System.Windows.Forms.ToolStrip toolStrip1; private System.Windows.Forms.ToolStripButton btnSave; private System.Windows.Forms.ToolStripButton btnLoad; + private System.Windows.Forms.SplitContainer splitContainer1; + private System.Windows.Forms.TabControl tabControl1; + private System.Windows.Forms.TabPage tabMeasurements; + private System.Windows.Forms.TabPage tabParts; + private System.Windows.Forms.TextBox txtMeasurements; + private System.Windows.Forms.TextBox txtParts; } } diff --git a/MathSample/FormMathSample.cs b/MathSample/FormMathSample.cs index cafa41c..28fbcb6 100644 --- a/MathSample/FormMathSample.cs +++ b/MathSample/FormMathSample.cs @@ -8,6 +8,8 @@ using System.Text; using System.Windows.Forms; +using Newtonsoft.Json; + namespace MathSample { public partial class FormMathSample : Form @@ -18,13 +20,43 @@ public partial class FormMathSample : Form public FormMathSample() { InitializeComponent(); + + context.Measurements = new List() + { + new Measurement() + { + Type = "Beam", + Length = 50, + Count = 2, + Selections = new Dictionary() + { + { "BeamType", "Header" }, + { "Material", "LVL" }, + { "Grade", "#1" }, + { "Thickness", 2 }, + { "Width", 4 } + } + } + }; + + txtMeasurements.Text = JsonConvert.SerializeObject(context.Measurements, Formatting.Indented); + + context.OnExecutionFinished += Context_OnExecutionFinished; + } + + private void Context_OnExecutionFinished() + { + txtParts.Text = JsonConvert.SerializeObject(context.Parts, Formatting.Indented); } private void FormMathSample_Load(object sender, EventArgs e) { //Context assignment controlNodeEditor.nodesControl.Context = context; - controlNodeEditor.nodesControl.OnNodeContextSelected += NodesControlOnOnNodeContextSelected; + controlNodeEditor.nodesControl.OnNodeContextSelected += NodesControlOnOnNodeContextSelected; + + // Add default nodes + AddDefaultNodes(); } private void NodesControlOnOnNodeContextSelected(object o) @@ -55,8 +87,6 @@ private void btnSave_Click(object sender, EventArgs e) byte[] graphData = controlNodeEditor.nodesControl.Serialize(); File.WriteAllBytes(saveFileDialog.FileName, graphData); } - MessageBox.Show("Node graph saved successfully!", "Save Complete", - MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { @@ -89,8 +119,6 @@ private void btnLoad_Click(object sender, EventArgs e) byte[] graphData = File.ReadAllBytes(openFileDialog.FileName); controlNodeEditor.nodesControl.Deserialize(graphData); } - MessageBox.Show("Node graph loaded successfully!", "Load Complete", - MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { @@ -99,5 +127,18 @@ private void btnLoad_Click(object sender, EventArgs e) } } } + + private void AddDefaultNodes() + { + // Only add default nodes if the graph is empty + var existingNodes = controlNodeEditor.nodesControl.GetNodes(); + if (existingNodes.Count > 0) return; + + // Add Starter node by method name + controlNodeEditor.nodesControl.AddNodeByMethodName("Starter", 50, 50); + + // Add Parts List node by method name + controlNodeEditor.nodesControl.AddNodeByMethodName("PartsList", 300, 50); + } } } diff --git a/MathSample/MathSample.csproj b/MathSample/MathSample.csproj index f1f6f3a..3fa3fe6 100644 --- a/MathSample/MathSample.csproj +++ b/MathSample/MathSample.csproj @@ -35,6 +35,9 @@ false + + ..\packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll + @@ -75,6 +78,7 @@ PreserveNewest + SettingsSingleFileGenerator Settings.Designer.cs diff --git a/MathSample/PartCalculation.cs b/MathSample/PartCalculation.cs index 6107044..fe10574 100644 --- a/MathSample/PartCalculation.cs +++ b/MathSample/PartCalculation.cs @@ -12,10 +12,77 @@ namespace MathSample // method corresponds to a node by attribute decoration public class PartCalculation : INodesContext { + public event Action OnExecutionFinished; + public NodeVisual CurrentProcessingNode { get; set; } public event Action FeedbackInfo; - [Node("Create Part", "Parts", "Basic", "Create a part", true)] + public List Measurements { get; set; } + public List Parts { get; set; } + + public PartCalculation() + { + Measurements = new List(); + Parts = new List(); + } + + public void FinishExecution() + { + OnExecutionFinished?.Invoke(); + } + + [Node("Measurement List", "Measurements", "Basic", "Get the current measurement list", false)] + public void MeasurementList(out List measurements) + { + measurements = Measurements; + } + + [Node("Number Selection", "Measurements", "Basic", "Select a number from the measurement", false)] + public void NumberSelection(Measurement measurement, string selectionName, out double selectionValue) + { + if (measurement.Selections.TryGetValue(selectionName, out object value) && double.TryParse(value?.ToString(), out double parsedValue)) + { + selectionValue = parsedValue; + } + else + { + selectionValue = 0; + } + } + + [Node("String Selection", "Measurements", "Basic", "Select a string from the measurement", false)] + public void StringSelection(Measurement measurement, string selectionName, out string selectionValue) + { + if (measurement.Selections.TryGetValue(selectionName, out object value)) + { + selectionValue = value.ToString(); + } + else + { + selectionValue = string.Empty; + } + } + + [Node("Measurement Length", "Measurements", "Basic", "Get the length of the measurement", false)] + public void MeasurementLength(Measurement measurement, out double length) + { + length = measurement.Length; + } + + [Node("If Else", "Flow Control", "Basic", "Standard If Else flow control", false, flowControlHandler: typeof(IfElseFlowControl))] + public void IfElse(bool condition, ExecutionPath enter, out ExecutionPath ifTrue, out ExecutionPath ifFalse) + { + ifTrue = new ExecutionPath(); + ifFalse = new ExecutionPath(); + } + + [Node("Boolean Value", "Constants", "Basic", "Allows to output a simple boolean value.", false)] + public void BooleanValue(bool inValue, out bool outValue) + { + outValue = inValue; + } + + [Node("Create Part", "Parts", "Basic", "Create a part")] public void CreatePart(string sku, string description, string package, float quantity, string unitOfMeasure, out Part part) { part = new Part @@ -29,9 +96,9 @@ public void CreatePart(string sku, string description, string package, float qua } [Node("Parts List", "Parts", "Basic", "Create a list of parts", false)] - public void PartsList(ExecutionPath calculationEnd, IEnumerable parts) + public void PartsList(ExecutionPath calculationEnd, List parts) { - + Parts = parts; } [Node("String Value", "Constants", "Basic", "Allows to output a simple string value.", false)] @@ -46,15 +113,15 @@ public void StringListValue(string[] inValue, out string[] outValue) outValue = inValue; } - [Node("For Each", "Loops", "Functional", "Transforms each item in a collection and returns the results.", true, + [Node("For Each", "Flow Control", "Functional", "Transforms each item in a collection and returns the results.", true, flowControlHandler: typeof(ForEachFlowControl), Width = 250)] [DynamicNode] public void ForEach( - [DynamicType(TypeGroup = "InputType")] IEnumerable inputCollection, + [DynamicType(TypeGroup = "InputType")] List inputCollection, [LoopFeedback][DynamicType(TypeGroup = "OutputType")] object loopResult, [DynamicType(TypeGroup = "InputType", ExtractElementType = true, DerivedFrom = nameof(inputCollection))] out object currentItemInLoop, out ExecutionPath forEachItemLoop, - [DynamicType(TypeGroup = "OutputType", WrapInCollection = true)] out IEnumerable forEachResult) + [DynamicType(TypeGroup = "OutputType", WrapInCollection = true)] out List forEachResult) { // Initialize outputs - actual values will be set by the flow control handler currentItemInLoop = null; @@ -62,38 +129,48 @@ public void ForEach( forEachResult = null; } + [Node("Pass Through", "Flow Control", "Functional", "Pass through a variable with control flow.", true)] + [DynamicNode] + public void PassThrough( + [DynamicType(TypeGroup = "Input")] object input, + [DynamicType(TypeGroup = "Input")] out object output) + { + output = input; + } + [Node("Value", "Constants", "Basic", "Allows to output a simple value.", false)] - public void InputValue(float inValue, out float outValue) + public void InputValue(double inValue, out double outValue) { outValue = inValue; } [Node("Add", "Math", "Basic", "Adds two input values.", false)] - public void Add(float a, float b, out float result) + public void Add(double a, double b, out double result) { result = a + b; } - [Node("Subtract", "Math", "Basic", "Substracts two input values.", true)] - public void Subtract(float a, float b, out float result) + [Node("Subtract", "Math", "Basic", "Substracts two input values.", false)] + public void Subtract(double a, double b, out double result) { result = a - b; } - [Node("Multiply", "Math", "Basic", "Multiplies two input values.", true)] - public void Multiply(float a, float b, out float result) + [Node("Multiply", "Math", "Basic", "Multiplies two input values.", false)] + public void Multiply(float a, double b, out double result) { result = a * b; } - [Node("Divide", "Math", "Basic", "Divides two input values.", true)] - public void Divide(float a, float b, out float result) + [Node("Divide", "Math", "Basic", "Divides two input values.", false)] + public void Divide(float a, double b, out double result) { result = a / b; } - [Node("Show Value", "Debug", "Basic", "Shows input value in the message box.")] - public void ShowMessageBox(object x) + [Node("Show Value", "Debug", "Basic", "Shows input value in the message box.", true)] + [DynamicNode] + public void ShowMessageBox([DynamicType(TypeGroup = "Input")] object x) { string valueToShow; if (x == null) @@ -112,28 +189,32 @@ public void ShowMessageBox(object x) MessageBox.Show(valueToShow, "Show Value", MessageBoxButtons.OK, MessageBoxIcon.Information); } - [Node("To Upper", "Operators", "String", "Converts a string to uppercase.", true)] + [Node("To Upper", "Operators", "String", "Converts a string to uppercase.", false)] public void ToUpper(string input, out string output) { output = input?.ToUpper(); } - [Node("Concatenate", "Operators", "String", "Concatenates two strings.", true)] + [Node("Concatenate", "Operators", "String", "Concatenates two strings.", false)] public void Concatenate(string a, string b, out string result) { result = a + b; } - [Node("To String", "Operators", "String", "Converts to a string.", true)] - public void ToStringNode(object a, out string result) + [Node("To String", "Operators", "String", "Converts to a string.", false)] + [DynamicNode] + public void ToStringNode([DynamicType(TypeGroup = "Input")] object a, out string result) { result = a?.ToString(); } - [Node("To List", "Operators", "List", "Converts to a string.", true)] - public void ToListNode(object a, out List list) + [Node("To List", "Operators", "List", "Creates a list containing a single item.", false)] + [DynamicNode] + public void ToListNode( + [DynamicType(TypeGroup = "Input")] object item, + [DynamicType(TypeGroup = "Input", WrapInCollection = true)] out List list) { - list = new List() { a }; + list = new List() { item }; } [Node("Starter", "Helper", "Basic", "Starts execution", true, true)] diff --git a/MathSample/packages.config b/MathSample/packages.config new file mode 100644 index 0000000..0b14af3 --- /dev/null +++ b/MathSample/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/NodeEditor/FlowControls/ForeachFlowControl.cs b/NodeEditor/FlowControls/ForeachFlowControl.cs index f76ce56..e7a9c18 100644 --- a/NodeEditor/FlowControls/ForeachFlowControl.cs +++ b/NodeEditor/FlowControls/ForeachFlowControl.cs @@ -82,55 +82,18 @@ public void ExecuteFlowControl( { results.Add(transformedItem); } - else - { - // If no transformation provided, use the original item - results.Add(item); - } + //else + //{ + // // If no transformation provided, use the original item + // results.Add(item); + //} index++; } - // Set the final results in the appropriate format based on the expected output type - if (currentNode != null) - { - Type expectedOutputType = currentNode.GetSocketRuntimeType(FOR_EACH_RESULT); - if (expectedOutputType != null) - { - if (expectedOutputType.IsArray) - { - // Create properly typed array - Array typedArray = Array.CreateInstance(resultType, results.Count); - results.CopyTo(typedArray, 0); - nodeContext[FOR_EACH_RESULT] = typedArray; - } - else if (expectedOutputType.IsGenericType && - (expectedOutputType.GetGenericTypeDefinition() == typeof(IEnumerable<>) || - expectedOutputType.GetGenericTypeDefinition() == typeof(IList<>) || - expectedOutputType.GetGenericTypeDefinition() == typeof(ICollection<>))) - { - // For interface types, return the List we already created - nodeContext[FOR_EACH_RESULT] = results; - } - else - { - // Default to array for unknown types - Array typedArray = Array.CreateInstance(resultType, results.Count); - results.CopyTo(typedArray, 0); - nodeContext[FOR_EACH_RESULT] = typedArray; - } - } - else - { - // Fallback to object array - nodeContext[FOR_EACH_RESULT] = results.Cast().ToArray(); - } - } - else - { - // Fallback to object array - nodeContext[FOR_EACH_RESULT] = results.Cast().ToArray(); - } + // Always return a List for consistency and better type handling + // Lists work better with our type system and avoid array covariance issues + nodeContext[FOR_EACH_RESULT] = results; // Execute the exit path with the complete results executeOutputPath(EXIT); diff --git a/NodeEditor/FlowControls/IfElseFlowControl.cs b/NodeEditor/FlowControls/IfElseFlowControl.cs new file mode 100644 index 0000000..8db5597 --- /dev/null +++ b/NodeEditor/FlowControls/IfElseFlowControl.cs @@ -0,0 +1,59 @@ +using System; + +namespace NodeEditor.FlowControls +{ + /// + /// Flow control implementation for conditional branching + /// + public class IfElseFlowControl : IFlowControlNode + { + private const string CONDITION = "condition"; + private const string IF_TRUE = "ifTrue"; + private const string IF_FALSE = "ifFalse"; + private const string EXIT = "Exit"; + + public void ExecuteFlowControl( + INodesContext context, + DynamicNodeContext nodeContext, + Action executeOutputPath, + Func shouldBreak) + { + // Check if we should break execution + if (shouldBreak()) + { + return; + } + + // Get the condition from the node context + object conditionValue = nodeContext[CONDITION]; + bool condition; + + // Convert the condition to boolean + if (conditionValue is bool boolValue) + { + condition = boolValue; + } + else if (conditionValue != null && bool.TryParse(conditionValue.ToString(), out bool parsedValue)) + { + condition = parsedValue; + } + else + { + throw new ArgumentException($"IfElse node condition must be a boolean value. Received: {conditionValue?.GetType().Name ?? "null"}"); + } + + // Execute the appropriate path based on the condition + if (condition) + { + executeOutputPath(IF_TRUE); + } + else + { + executeOutputPath(IF_FALSE); + } + + // Execute the exit path after the conditional branch completes + executeOutputPath(EXIT); + } + } +} \ No newline at end of file diff --git a/NodeEditor/INodesContext.cs b/NodeEditor/INodesContext.cs index 5b07fb7..9cf2a8b 100644 --- a/NodeEditor/INodesContext.cs +++ b/NodeEditor/INodesContext.cs @@ -27,15 +27,19 @@ namespace NodeEditor /// public interface INodesContext { - /// - /// Property that is set to actual processed node during execution process. - /// - NodeVisual CurrentProcessingNode { get; set; } + event Action OnExecutionFinished; /// /// Event that can be raised when your application would to return some feedback information /// to the nodes graph. (Message, Related Node, Feedback Type, Tag - Anything, BreakExecution) /// event Action FeedbackInfo; + + /// + /// Property that is set to actual processed node during execution process. + /// + NodeVisual CurrentProcessingNode { get; set; } + + void FinishExecution(); } } diff --git a/NodeEditor/NodeEditor.csproj b/NodeEditor/NodeEditor.csproj index dc818df..1ab9de3 100644 --- a/NodeEditor/NodeEditor.csproj +++ b/NodeEditor/NodeEditor.csproj @@ -53,6 +53,7 @@ + diff --git a/NodeEditor/NodeVisual.cs b/NodeEditor/NodeVisual.cs index 5118261..723d2dd 100644 --- a/NodeEditor/NodeVisual.cs +++ b/NodeEditor/NodeVisual.cs @@ -16,6 +16,7 @@ */ using System; +using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Drawing; @@ -437,12 +438,33 @@ internal void Execute(INodesContext context) context.CurrentProcessingNode = this; DynamicNodeContext dc = GetNodeContext(); - var parametersDict = Type.GetParameters().OrderBy(x => x.Position).ToDictionary(x => x.Name, x => dc[x.Name]); - var parameters = parametersDict.Values.ToArray(); + ParameterInfo[] methodParams = Type.GetParameters().OrderBy(x => x.Position).ToArray(); + Dictionary parametersDict = new Dictionary(); + object[] parameters = new object[methodParams.Length]; + + // Convert parameters to the expected types + for (int i = 0; i < methodParams.Length; i++) + { + ParameterInfo param = methodParams[i]; + object value = dc[param.Name]; + Type expectedType = param.ParameterType; + + // Handle ref/out parameters + if (expectedType.IsByRef) + { + expectedType = expectedType.GetElementType(); + } + + // Convert the value if necessary using the shared type conversion logic + object convertedValue = TypePropagation.ConvertValue(value, expectedType); + + parametersDict[param.Name] = convertedValue; + parameters[i] = convertedValue; + } int ndx = 0; Type.Invoke(context, parameters); - foreach (var kv in parametersDict.ToArray()) + foreach (KeyValuePair kv in parametersDict.ToArray()) { parametersDict[kv.Key] = parameters[ndx]; ndx++; diff --git a/NodeEditor/NodesControl.cs b/NodeEditor/NodesControl.cs index 7473580..6d49541 100644 --- a/NodeEditor/NodesControl.cs +++ b/NodeEditor/NodesControl.cs @@ -54,6 +54,8 @@ internal class NodeToken private NodeVisual dragSocketNode; private PointF dragConnectionBegin; private PointF dragConnectionEnd; + private NodeConnection dragConnection; + private bool isDraggingConnection; private Stack executionStack = new Stack(); private bool rebuildConnectionDictionary = true; private Dictionary connectionDictionary = new Dictionary(); @@ -167,13 +169,13 @@ private void NodesControl_Paint(object sender, PaintEventArgs e) if (dragSocket != null) { - var pen = new Pen(Color.Black, 2); + Pen pen = new Pen(Color.Black, 2); NodesGraph.DrawConnection(e.Graphics, pen, dragConnectionBegin, dragConnectionEnd); } if (selectionStart != PointF.Empty) { - var rect = Rectangle.Round(MakeRect(selectionStart, selectionEnd)); + Rectangle rect = Rectangle.Round(MakeRect(selectionStart, selectionEnd)); e.Graphics.FillRectangle(new SolidBrush(Color.FromArgb(50, Color.CornflowerBlue)), rect); e.Graphics.DrawRectangle(new Pen(Color.DodgerBlue), rect); } @@ -183,166 +185,287 @@ private void NodesControl_Paint(object sender, PaintEventArgs e) private static RectangleF MakeRect(PointF a, PointF b) { - var x1 = a.X; - var x2 = b.X; - var y1 = a.Y; - var y2 = b.Y; + float x1 = a.X; + float x2 = b.X; + float y1 = a.Y; + float y2 = b.Y; return new RectangleF(Math.Min(x1, x2), Math.Min(y1, y2), Math.Abs(x2 - x1), Math.Abs(y2 - y1)); } private void NodesControl_MouseMove(object sender, MouseEventArgs e) { - var em = PointToScreen(e.Location); + Point em = PointToScreen(e.Location); if (selectionStart != PointF.Empty) { selectionEnd = e.Location; } if (mdown) { - foreach (var node in graph.Nodes.Where(x => x.IsSelected)) + if (!isDraggingConnection && dragSocket == null) { - node.X += em.X - lastmpos.X; - node.Y += em.Y - lastmpos.Y; - node.DiscardCache(); - node.LayoutEditor(); - } - if (graph.Nodes.Exists(x => x.IsSelected)) - { - var n = graph.Nodes.FirstOrDefault(x => x.IsSelected); - var bound = new RectangleF(new PointF(n.X, n.Y), n.GetNodeBounds()); - foreach (var node in graph.Nodes.Where(x => x.IsSelected)) + // Regular node dragging - only when not dragging sockets or connections + foreach (NodeVisual node in graph.Nodes.Where(x => x.IsSelected)) + { + node.X += em.X - lastmpos.X; + node.Y += em.Y - lastmpos.Y; + node.DiscardCache(); + node.LayoutEditor(); + } + if (graph.Nodes.Exists(x => x.IsSelected)) { - bound = RectangleF.Union(bound, new RectangleF(new PointF(node.X, node.Y), node.GetNodeBounds())); + NodeVisual n = graph.Nodes.FirstOrDefault(x => x.IsSelected); + RectangleF bound = new RectangleF(new PointF(n.X, n.Y), n.GetNodeBounds()); + foreach (NodeVisual node in graph.Nodes.Where(x => x.IsSelected)) + { + bound = RectangleF.Union(bound, new RectangleF(new PointF(node.X, node.Y), node.GetNodeBounds())); + } + OnShowLocation(bound); } - OnShowLocation(bound); + } - Invalidate(); if (dragSocket != null) { - var center = new PointF(dragSocket.X + dragSocket.Width / 2f, dragSocket.Y + dragSocket.Height / 2f); - if (dragSocket.Input) + if (isDraggingConnection) { - dragConnectionBegin.X += em.X - lastmpos.X; - dragConnectionBegin.Y += em.Y - lastmpos.Y; - dragConnectionEnd = center; - OnShowLocation(new RectangleF(dragConnectionBegin, new SizeF(10, 10))); + // Connection dragging: floating end follows mouse, fixed end stays at socket + PointF mousePos = PointToClient(em); + + // Get the fixed socket center (the one we're NOT dragging from) + PointF fixedSocketCenter = new PointF(dragSocket.X + dragSocket.Width / 2f, dragSocket.Y + dragSocket.Height / 2f); + + if (dragSocket.Input) + { + // Dragging towards a new input, keep current input socket fixed + dragConnectionBegin = mousePos; // Floating end follows mouse + dragConnectionEnd = fixedSocketCenter; // Fixed end stays at input socket + OnShowLocation(new RectangleF(dragConnectionBegin, new SizeF(10, 10))); + } + else + { + // Dragging towards a new output, keep current output socket fixed + dragConnectionBegin = fixedSocketCenter; // Fixed end stays at output socket + dragConnectionEnd = mousePos; // Floating end follows mouse + OnShowLocation(new RectangleF(dragConnectionEnd, new SizeF(10, 10))); + } } else { - dragConnectionBegin = center; - dragConnectionEnd.X += em.X - lastmpos.X; - dragConnectionEnd.Y += em.Y - lastmpos.Y; - OnShowLocation(new RectangleF(dragConnectionEnd, new SizeF(10, 10))); + // Regular socket dragging: existing logic + PointF center = new PointF(dragSocket.X + dragSocket.Width / 2f, dragSocket.Y + dragSocket.Height / 2f); + if (dragSocket.Input) + { + dragConnectionBegin.X += em.X - lastmpos.X; + dragConnectionBegin.Y += em.Y - lastmpos.Y; + dragConnectionEnd = center; + OnShowLocation(new RectangleF(dragConnectionBegin, new SizeF(10, 10))); + } + else + { + dragConnectionBegin = center; + dragConnectionEnd.X += em.X - lastmpos.X; + dragConnectionEnd.Y += em.Y - lastmpos.Y; + OnShowLocation(new RectangleF(dragConnectionEnd, new SizeF(10, 10))); + } } - } lastmpos = em; } - needRepaint = true; } - private void NodesControl_MouseDown(object sender, MouseEventArgs e) + /// + /// Handles mouse down on connections for dragging/reconnecting + /// + /// Mouse location + /// True if connection dragging was initiated + private bool TryStartConnectionDrag(Point location) { - if (e.Button == MouseButtons.Left) + NodeConnection hitConnection = graph.GetConnectionAtPoint(new PointF(location.X, location.Y)); + if (hitConnection == null || mdown) return false; + + // Start dragging the connection + isDraggingConnection = true; + dragConnection = hitConnection; + + // Remove the connection temporarily while dragging + graph.Connections.Remove(hitConnection); + rebuildConnectionDictionary = true; + Invalidate(); // Force immediate repaint to hide the original connection + + // Determine which end to drag based on mouse position + SocketVisual outputSocket = hitConnection.OutputNode.GetSockets().FirstOrDefault(x => x.Name == hitConnection.OutputSocketName); + SocketVisual inputSocket = hitConnection.InputNode.GetSockets().FirstOrDefault(x => x.Name == hitConnection.InputSocketName); + + if (outputSocket != null && inputSocket != null) { - selectionStart = PointF.Empty; + RectangleF outputBounds = outputSocket.GetBounds(); + RectangleF inputBounds = inputSocket.GetBounds(); + PointF outputCenter = outputBounds.Location + new SizeF(outputBounds.Width / 2f, outputBounds.Height / 2f); + PointF inputCenter = inputBounds.Location + new SizeF(inputBounds.Width / 2f, inputBounds.Height / 2f); - Focus(); + // Calculate distance to both ends + float distToOutput = (float)Math.Sqrt(Math.Pow(location.X - outputCenter.X, 2) + Math.Pow(location.Y - outputCenter.Y, 2)); + float distToInput = (float)Math.Sqrt(Math.Pow(location.X - inputCenter.X, 2) + Math.Pow(location.Y - inputCenter.Y, 2)); - if ((ModifierKeys & Keys.Shift) != Keys.Shift) + // Drag from the closer end - disconnect that end and keep the farther end fixed + if (distToOutput < distToInput) { - graph.Nodes.ForEach(x => x.IsSelected = false); + // Closer to output - disconnect from output, keep input fixed, drag towards new output + dragSocket = inputSocket; + dragSocketNode = hitConnection.InputNode; + dragConnectionBegin = new PointF(location.X, location.Y); // Floating end starts at mouse + dragConnectionEnd = inputCenter; // Fixed end stays at input socket } + else + { + // Closer to input - disconnect from input, keep output fixed, drag towards new input + dragSocket = outputSocket; + dragSocketNode = hitConnection.OutputNode; + dragConnectionBegin = outputCenter; // Fixed end stays at output socket + dragConnectionEnd = new PointF(location.X, location.Y); // Floating end starts at mouse + } + } - var node = - graph.Nodes.OrderBy(x => x.Order).FirstOrDefault( - x => new RectangleF(new PointF(x.X, x.Y), x.GetHeaderSize()).Contains(e.Location)); + mdown = true; + lastmpos = PointToScreen(location); + + // Handle type propagation after disconnection - use unified propagation + PropagateTypesForConnection(hitConnection); - if (node != null && !mdown) + return true; + } + + /// + /// Handles mouse down on node headers for selection and dragging + /// + /// Mouse location + /// The selected node if header was clicked, null otherwise + private NodeVisual TrySelectNodeHeader(Point location) + { + NodeVisual node = graph.Nodes.OrderBy(x => x.Order).FirstOrDefault( + x => new RectangleF(new PointF(x.X, x.Y), x.GetHeaderSize()).Contains(location)); + + if (node != null && !mdown) + { + node.IsSelected = true; + node.Order = graph.Nodes.Min(x => x.Order) - 1; + if (node.CustomEditor != null) { + node.CustomEditor.BringToFront(); + } + mdown = true; + lastmpos = PointToScreen(location); + Refresh(); + } + + return node; + } + + /// + /// Handles mouse down on sockets for connection creation/modification + /// + /// Mouse location + /// The node that was already selected (if any) + /// The node if socket interaction occurred, null otherwise + private NodeVisual TryHandleSocketInteraction(Point location, NodeVisual targetNode) + { + if (targetNode != null || mdown) return targetNode; + + NodeVisual nodeWhole = graph.Nodes.OrderBy(x => x.Order).FirstOrDefault( + x => new RectangleF(new PointF(x.X, x.Y), x.GetNodeBounds()).Contains(location)); - node.IsSelected = true; + if (nodeWhole == null) return null; - node.Order = graph.Nodes.Min(x => x.Order) - 1; - if (node.CustomEditor != null) + targetNode = nodeWhole; + SocketVisual socket = nodeWhole.GetSockets().FirstOrDefault(x => x.GetBounds().Contains(location)); + + if (socket == null) return targetNode; + + if ((ModifierKeys & Keys.Control) == Keys.Control) + { + // Ctrl+Click: Disconnect existing connection and start dragging from the other end + NodeConnection connection = graph.Connections.FirstOrDefault( + x => x.InputNode == nodeWhole && x.InputSocketName == socket.Name); + + if (connection != null) + { + dragSocket = connection.OutputNode.GetSockets() + .FirstOrDefault(x => x.Name == connection.OutputSocketName); + dragSocketNode = connection.OutputNode; + } + else + { + connection = graph.Connections.FirstOrDefault( + x => x.OutputNode == nodeWhole && x.OutputSocketName == socket.Name); + + if (connection != null) { - node.CustomEditor.BringToFront(); + dragSocket = connection.InputNode.GetSockets() + .FirstOrDefault(x => x.Name == connection.InputSocketName); + dragSocketNode = connection.InputNode; } - mdown = true; - lastmpos = PointToScreen(e.Location); + } - Refresh(); + graph.Connections.Remove(connection); + rebuildConnectionDictionary = true; + + // Handle type propagation after disconnection - use unified propagation + if (connection != null) + { + PropagateTypesForConnection(connection); } - if (node == null && !mdown) + } + else + { + // Normal click: Start dragging from this socket + dragSocket = socket; + dragSocketNode = nodeWhole; + } + + dragConnectionBegin = new PointF(location.X, location.Y); + dragConnectionEnd = new PointF(location.X, location.Y); + mdown = true; + lastmpos = PointToScreen(location); + + return targetNode; + } + + private void NodesControl_MouseDown(object sender, MouseEventArgs e) + { + if (e.Button == MouseButtons.Left) + { + selectionStart = PointF.Empty; + Focus(); + + if ((ModifierKeys & Keys.Shift) != Keys.Shift) { - var nodeWhole = - graph.Nodes.OrderBy(x => x.Order).FirstOrDefault( - x => new RectangleF(new PointF(x.X, x.Y), x.GetNodeBounds()).Contains(e.Location)); - if (nodeWhole != null) - { - node = nodeWhole; - var socket = nodeWhole.GetSockets().FirstOrDefault(x => x.GetBounds().Contains(e.Location)); - if (socket != null) - { - if ((ModifierKeys & Keys.Control) == Keys.Control) - { - NodeConnection connection = - graph.Connections.FirstOrDefault( - x => x.InputNode == nodeWhole && x.InputSocketName == socket.Name); + graph.Nodes.ForEach(x => x.IsSelected = false); + } - if (connection != null) - { - dragSocket = - connection.OutputNode.GetSockets() - .FirstOrDefault(x => x.Name == connection.OutputSocketName); - dragSocketNode = connection.OutputNode; - } - else - { - connection = - graph.Connections.FirstOrDefault( - x => x.OutputNode == nodeWhole && x.OutputSocketName == socket.Name); - - if (connection != null) - { - dragSocket = - connection.InputNode.GetSockets() - .FirstOrDefault(x => x.Name == connection.InputSocketName); - dragSocketNode = connection.InputNode; - } - } + // Try each type of interaction in order of priority - sockets first, then connections + NodeVisual selectedNode = TrySelectNodeHeader(e.Location); - graph.Connections.Remove(connection); - rebuildConnectionDictionary = true; + if (selectedNode == null) + { + selectedNode = TryHandleSocketInteraction(e.Location, selectedNode); + } - // Handle type propagation after disconnection - if (connection != null && connection.InputNode.HasDynamicTypeSupport()) - { - connection.InputNode.PropagateTypes(graph); - PropagateTypesDownstream(connection.InputNode); - } - } - else - { - dragSocket = socket; - dragSocketNode = nodeWhole; - } - dragConnectionBegin = e.Location; - dragConnectionEnd = e.Location; - mdown = true; - lastmpos = PointToScreen(e.Location); - } - } - else - { - selectionStart = selectionEnd = e.Location; - } + // Only try connection dragging if no socket interaction occurred + if (selectedNode == null && !mdown && TryStartConnectionDrag(e.Location)) + { + return; // Connection dragging started + } + + if (selectedNode == null && !mdown) + { + // Start selection rectangle + selectionStart = selectionEnd = e.Location; } - if (node != null) + + if (selectedNode != null) { - OnNodeContextSelected(node.GetNodeContext()); + OnNodeContextSelected(selectedNode.GetNodeContext()); } } @@ -363,20 +486,9 @@ private bool IsConnectable(SocketVisual a, SocketVisual b) if (outputType == null || inputType == null) return false; - // Check for exact match - if (outputType == inputType) return true; - - // Check for inheritance - if (outputType.IsSubclassOf(inputType)) return true; - - // Check for interface implementation - if (inputType.IsInterface && inputType.IsAssignableFrom(outputType)) return true; - - // Special case: Check if output type can be assigned to input type - // This handles cases like string[] to IEnumerable - if (inputType.IsAssignableFrom(outputType)) return true; - - return false; + // Use TypePropagation's more sophisticated type compatibility check + // which handles generic collections properly + return TypePropagation.AreTypesCompatible(outputType, inputType); } private Type TypeResolver(Assembly assembly, string name, bool inh) @@ -401,20 +513,75 @@ private void NodesControl_MouseUp(object sender, MouseEventArgs e) { if (selectionStart != PointF.Empty) { - var rect = MakeRect(selectionStart, selectionEnd); + RectangleF rect = MakeRect(selectionStart, selectionEnd); graph.Nodes.ForEach( x => x.IsSelected = rect.Contains(new RectangleF(new PointF(x.X, x.Y), x.GetNodeBounds()))); selectionStart = PointF.Empty; } - if (dragSocket != null) + // Handle connection dragging + if (isDraggingConnection && dragSocket != null && dragConnection != null) + { + NodeVisual nodeWhole = + graph.Nodes.OrderBy(x => x.Order).FirstOrDefault( + x => new RectangleF(new PointF(x.X, x.Y), x.GetNodeBounds()).Contains(e.Location)); + + bool connectionRecreated = false; + + if (nodeWhole != null) + { + SocketVisual socket = nodeWhole.GetSockets().FirstOrDefault(x => x.GetBounds().Contains(e.Location)); + if (socket != null && IsConnectable(dragSocket, socket) && dragSocket.Input != socket.Input) + { + // Recreate the connection with new endpoint + NodeConnection nc = new NodeConnection(); + if (!dragSocket.Input) + { + nc.OutputNode = dragSocketNode; + nc.OutputSocketName = dragSocket.Name; + nc.InputNode = nodeWhole; + nc.InputSocketName = socket.Name; + } + else + { + nc.InputNode = dragSocketNode; + nc.InputSocketName = dragSocket.Name; + nc.OutputNode = nodeWhole; + nc.OutputSocketName = socket.Name; + } + + graph.Connections.RemoveAll( + x => x.InputNode == nc.InputNode && x.InputSocketName == nc.InputSocketName); + + graph.Connections.Add(nc); + rebuildConnectionDictionary = true; + connectionRecreated = true; + + // Propagate types for dynamic nodes + PropagateTypesForConnection(nc); + } + } + + // If connection wasn't recreated, it's deleted (user dropped it in empty space) + if (!connectionRecreated) + { + // Connection is already removed from graph, just need to handle type propagation + // Handle type propagation for nodes that were connected to the deleted connection - use unified propagation + PropagateTypesForConnection(dragConnection); + } + + // Reset connection dragging state + isDraggingConnection = false; + dragConnection = null; + } + else if (dragSocket != null) { - var nodeWhole = + NodeVisual nodeWhole = graph.Nodes.OrderBy(x => x.Order).FirstOrDefault( x => new RectangleF(new PointF(x.X, x.Y), x.GetNodeBounds()).Contains(e.Location)); if (nodeWhole != null) { - var socket = nodeWhole.GetSockets().FirstOrDefault(x => x.GetBounds().Contains(e.Location)); + SocketVisual socket = nodeWhole.GetSockets().FirstOrDefault(x => x.GetBounds().Contains(e.Location)); if (socket != null) { if (IsConnectable(dragSocket, socket) && dragSocket.Input != socket.Input) @@ -456,6 +623,10 @@ private void NodesControl_MouseUp(object sender, MouseEventArgs e) /// /// Propagates types through the graph when a connection is made or removed /// + /// + /// Unified type propagation for a connection - handles both initial and cascading propagation + /// This is the single entry point for all type propagation to ensure consistency + /// private void PropagateTypesForConnection(NodeConnection connection) { if (connection == null) return; @@ -473,10 +644,47 @@ private void PropagateTypesForConnection(NodeConnection connection) } } - // Propagate types through all downstream nodes + // Always propagate downstream to ensure complete type flow PropagateTypesDownstream(connection.InputNode); } + /// + /// Propagates types for all connections in the graph using topological ordering + /// This ensures types flow from source nodes to sink nodes, preventing premature incompatibility detection + /// + private void PropagateAllConnectionTypes() + { + // Process in topological order to ensure types flow from sources to sinks + HashSet processedNodes = new HashSet(); + List connectionsToProcess = graph.Connections.ToList(); + + // First pass: Process connections from nodes with no inputs (source nodes) + // This ensures concrete types are established before dynamic nodes + List sourceNodes = graph.Nodes.Where(n => + !graph.Connections.Any(c => c.InputNode == n)).ToList(); + + foreach (NodeVisual sourceNode in sourceNodes) + { + List sourceConnections = connectionsToProcess + .Where(c => c.OutputNode == sourceNode).ToList(); + foreach (NodeConnection connection in sourceConnections) + { + PropagateTypesForConnection(connection); + processedNodes.Add(connection.InputNode); + } + } + + // Second pass: Process remaining connections + // These are connections in the middle of the graph + foreach (NodeConnection connection in connectionsToProcess) + { + if (!processedNodes.Contains(connection.InputNode)) + { + PropagateTypesForConnection(connection); + } + } + } + /// /// Propagates types to all nodes downstream from the given node /// @@ -485,6 +693,7 @@ private void PropagateTypesDownstream(NodeVisual startNode) HashSet visited = new HashSet(); Queue toProcess = new Queue(); toProcess.Enqueue(startNode); + bool needsInvalidate = false; while (toProcess.Count > 0) { @@ -502,6 +711,7 @@ private void PropagateTypesDownstream(NodeVisual startNode) if (conn.InputNode.HasDynamicTypeSupport()) { conn.InputNode.PropagateTypes(graph); + needsInvalidate = true; // Check for incompatible connections DynamicNodeAttribute nodeAttr = conn.InputNode.Type?.GetCustomAttribute(); @@ -517,6 +727,12 @@ private void PropagateTypesDownstream(NodeVisual startNode) } } } + + // Trigger a visual refresh if any types were updated + if (needsInvalidate) + { + Invalidate(); + } } /// @@ -586,32 +802,28 @@ private void DisconnectIncompatibleConnections(NodeVisual node) graph.Connections.Remove(conn); rebuildConnectionDictionary = true; - // Reset types for disconnected input node if it's dynamic - if (conn.InputNode.HasDynamicTypeSupport()) - { - conn.InputNode.PropagateTypes(graph); - PropagateTypesDownstream(conn.InputNode); - } + // Reset types for disconnected input node if it's dynamic - use unified propagation + PropagateTypesForConnection(conn); } } - + /// /// Disconnects connections that are no longer type-compatible (overload that returns removed connections) /// private void DisconnectIncompatibleConnections(NodeVisual node, List connectionsRemoved) { List connectionsToRemove = new List(); - + // Check input connections List inputConnections = graph.Connections .Where(c => c.InputNode == node) .ToList(); - + foreach (NodeConnection conn in inputConnections) { Type expectedType = node.GetSocketRuntimeType(conn.InputSocketName); Type actualType = conn.OutputNode.GetSocketRuntimeType(conn.OutputSocketName); - + if (expectedType != null && actualType != null) { if (!TypePropagation.AreTypesCompatible(actualType, expectedType)) @@ -620,7 +832,7 @@ private void DisconnectIncompatibleConnections(NodeVisual node, List outputConnections = graph.Connections .Where(c => c.OutputNode == node) @@ -663,11 +875,8 @@ private void DisconnectIncompatibleConnections(NodeVisual node, List affectedNodes = new HashSet(); - + foreach (NodeVisual selectedNode in graph.Nodes.Where(x => x.IsSelected)) { // Find all downstream nodes from this node before we delete connections CollectDownstreamNodes(selectedNode, affectedNodes); - + // Also collect nodes that have this node as input List incomingConnections = graph.Connections .Where(x => x.OutputNode == selectedNode) .ToList(); - + foreach (NodeConnection conn in incomingConnections) { if (conn.InputNode.HasDynamicTypeSupport() && !graph.Nodes.Any(n => n.IsSelected && n == conn.InputNode)) @@ -887,22 +1096,22 @@ private void DeleteSelectedNodes() } } } - + // Now remove the selected nodes and their connections foreach (NodeVisual selectedNode in graph.Nodes.Where(x => x.IsSelected)) { Controls.Remove(selectedNode.CustomEditor); graph.Connections.RemoveAll(x => x.OutputNode == selectedNode || x.InputNode == selectedNode); } - + graph.Nodes.RemoveAll(x => graph.Nodes.Where(n => n.IsSelected).Contains(x)); rebuildConnectionDictionary = true; - + // After deletion, update types for all affected nodes // We need to do this in multiple passes because disconnecting connections might affect more nodes HashSet processedNodes = new HashSet(); Queue nodesToProcess = new Queue(); - + // Initial set of affected nodes foreach (NodeVisual affectedNode in affectedNodes) { @@ -911,30 +1120,30 @@ private void DeleteSelectedNodes() nodesToProcess.Enqueue(affectedNode); } } - + while (nodesToProcess.Count > 0) { NodeVisual currentNode = nodesToProcess.Dequeue(); if (processedNodes.Contains(currentNode) || !graph.Nodes.Contains(currentNode)) continue; - + processedNodes.Add(currentNode); - + // Store connections before type propagation to see what changes List connectionsBefore = graph.Connections .Where(c => c.OutputNode == currentNode) .ToList(); - + // Propagate types currentNode.PropagateTypes(graph); - + // Check for incompatible connections after type reset DynamicNodeAttribute nodeAttr = currentNode.Type?.GetCustomAttribute(); if (nodeAttr != null && nodeAttr.AutoDisconnectIncompatible) { List connectionsRemoved = new List(); DisconnectIncompatibleConnections(currentNode, connectionsRemoved); - + // If connections were removed, add affected downstream nodes to processing queue foreach (NodeConnection removedConn in connectionsRemoved) { @@ -944,12 +1153,12 @@ private void DeleteSelectedNodes() } } } - + // Add directly connected downstream nodes List outputConnections = graph.Connections .Where(c => c.OutputNode == currentNode) .ToList(); - + foreach (NodeConnection conn in outputConnections) { if (!processedNodes.Contains(conn.InputNode)) @@ -961,7 +1170,7 @@ private void DeleteSelectedNodes() } Invalidate(); } - + /// /// Collects all nodes downstream from the given node /// @@ -970,7 +1179,7 @@ private void CollectDownstreamNodes(NodeVisual startNode, HashSet co List outgoingConnections = graph.Connections .Where(c => c.OutputNode == startNode) .ToList(); - + foreach (NodeConnection conn in outgoingConnections) { if (!collected.Contains(conn.InputNode) && !graph.Nodes.Any(n => n.IsSelected && n == conn.InputNode)) @@ -1124,6 +1333,70 @@ public List GetNodes(params string[] nodeNames) return nodes.ToList(); } + /// + /// Adds a node to the graph by method name at the specified coordinates + /// + /// Name of the method decorated with NodeAttribute + /// X coordinate for the node + /// Y coordinate for the node + /// True if the node was successfully added, false otherwise + public bool AddNodeByMethodName(string methodName, float x, float y) + { + if (Context == null) return false; + + var methods = Context.GetType().GetMethods(); + var nodeToken = methods.Select(m => new NodeToken() + { + Method = m, + Attribute = m.GetCustomAttributes(typeof(NodeAttribute), false) + .Cast() + .FirstOrDefault() + }).FirstOrDefault(n => n.Attribute != null && n.Method.Name == methodName); + + if (nodeToken != null) + { + var originalMouseLocation = lastMouseLocation; + lastMouseLocation = new Point((int)x, (int)y); + AddNodeToGraph(nodeToken); + lastMouseLocation = originalMouseLocation; + return true; + } + + return false; + } + + /// + /// Adds a node to the graph by node name at the specified coordinates + /// + /// Display name of the node as defined in NodeAttribute + /// X coordinate for the node + /// Y coordinate for the node + /// True if the node was successfully added, false otherwise + public bool AddNodeByName(string nodeName, float x, float y) + { + if (Context == null) return false; + + var methods = Context.GetType().GetMethods(); + var nodeToken = methods.Select(m => new NodeToken() + { + Method = m, + Attribute = m.GetCustomAttributes(typeof(NodeAttribute), false) + .Cast() + .FirstOrDefault() + }).FirstOrDefault(n => n.Attribute != null && n.Attribute.Name == nodeName); + + if (nodeToken != null) + { + var originalMouseLocation = lastMouseLocation; + lastMouseLocation = new Point((int)x, (int)y); + AddNodeToGraph(nodeToken); + lastMouseLocation = originalMouseLocation; + return true; + } + + return false; + } + public bool HasImpact(NodeVisual startNode, NodeVisual endNode) { var connections = graph.Connections.Where(x => x.OutputNode == startNode && !x.IsExecution); @@ -1372,16 +1645,10 @@ public void Deserialize(byte[] data) } br.ReadBytes(br.ReadInt32()); //read additional data } - - // Propagate types for all dynamic nodes after loading - foreach (NodeVisual node in graph.Nodes) - { - if (node.HasDynamicTypeSupport()) - { - node.PropagateTypes(graph); - } - } - + + // Propagate types for all connections after loading + PropagateAllConnectionTypes(); + Refresh(); } @@ -1618,15 +1885,9 @@ public void DeserializeFromJson(string json) graph.Connections.Add(connection); } } - - // Propagate types for all dynamic nodes after loading - foreach (NodeVisual node in graph.Nodes) - { - if (node.HasDynamicTypeSupport()) - { - node.PropagateTypes(graph); - } - } + + // Propagate types for all connections after loading + PropagateAllConnectionTypes(); rebuildConnectionDictionary = true; Refresh(); diff --git a/NodeEditor/NodesGraph.cs b/NodeEditor/NodesGraph.cs index 72663e8..499302e 100644 --- a/NodeEditor/NodesGraph.cs +++ b/NodeEditor/NodesGraph.cs @@ -143,5 +143,87 @@ public static PointF Lerp(PointF a, PointF b, float amount) return result; } + + /// + /// Tests if a point is near a connection line + /// + /// Point to test + /// Connection to test against + /// Distance threshold for hit detection + /// True if point is within threshold distance of the connection + public bool IsPointNearConnection(PointF point, NodeConnection connection, float threshold = 10f) + { + SocketVisual osoc = connection.OutputNode.GetSockets().FirstOrDefault(x => x.Name == connection.OutputSocketName); + if (osoc == null) return false; + + SocketVisual isoc = connection.InputNode.GetSockets().FirstOrDefault(x => x.Name == connection.InputSocketName); + if (isoc == null) return false; + + RectangleF outputSocketBounds = osoc.GetBounds(); + RectangleF inputSocketBounds = isoc.GetBounds(); + PointF begin = outputSocketBounds.Location + new SizeF(outputSocketBounds.Width / 2f, outputSocketBounds.Height / 2f); + PointF end = inputSocketBounds.Location + new SizeF(inputSocketBounds.Width / 2f, inputSocketBounds.Height / 2f); + + return IsPointNearCurve(point, begin, end, threshold); + } + + /// + /// Tests if a point is near the curved connection line + /// + private static bool IsPointNearCurve(PointF point, PointF output, PointF input, float threshold) + { + const int testPoints = 20; // Fewer test points for performance + float minDistance = float.MaxValue; + + for (int i = 0; i < testPoints; i++) + { + float amount = i / (float)(testPoints - 1); + + float lx = Lerp(output.X, input.X, amount); + double d = Math.Min(Math.Abs(input.X - output.X), 100); + PointF a = new PointF((float)Scale(amount, 0, 1, output.X, output.X + d), output.Y); + PointF b = new PointF((float)Scale(amount, 0, 1, input.X - d, input.X), input.Y); + + double bas = Sat(Scale(amount, 0.1, 0.9, 0, 1)); + double cos = Math.Cos(bas * Math.PI); + if (cos < 0) + { + cos = -Math.Pow(-cos, 0.2); + } + else + { + cos = Math.Pow(cos, 0.2); + } + amount = (float)cos * -0.5f + 0.5f; + + PointF curvePoint = Lerp(a, b, amount); + + float distance = (float)Math.Sqrt(Math.Pow(point.X - curvePoint.X, 2) + Math.Pow(point.Y - curvePoint.Y, 2)); + minDistance = Math.Min(minDistance, distance); + + if (minDistance <= threshold) + return true; + } + + return false; + } + + /// + /// Finds the connection at the specified point + /// + /// Point to test + /// Distance threshold for hit detection + /// The connection at the point, or null if none found + public NodeConnection GetConnectionAtPoint(PointF point, float threshold = 10f) + { + foreach (NodeConnection connection in Connections) + { + if (IsPointNearConnection(point, connection, threshold)) + { + return connection; + } + } + return null; + } } } diff --git a/NodeEditor/TypePropagation.cs b/NodeEditor/TypePropagation.cs index dee2c77..03398d4 100644 --- a/NodeEditor/TypePropagation.cs +++ b/NodeEditor/TypePropagation.cs @@ -62,10 +62,15 @@ public static void PropagateNodeTypes(NodeVisual node, NodesGraph graph) if (connection != null) { - Type inferredType = GetActualType(connection); + Type inferredType = ResolveTypeRecursively(connection, graph); if (inferredType == null) inferredType = connection.OutputSocket.Type; + // For type propagation, use the actual source type, not the converted type + // This preserves type information for ExtractElementType operations + // For example, double[] should remain double[] for type propagation, + // even though it converts to IEnumerable at runtime + // Store the inferred type for this parameter's group if (!string.IsNullOrEmpty(param.Value.TypeGroup)) { @@ -105,7 +110,7 @@ public static void PropagateNodeTypes(NodeVisual node, NodesGraph graph) if (sourceConnection != null) { - targetType = GetActualType(sourceConnection) ?? sourceConnection.OutputSocket.Type; + targetType = ResolveTypeRecursively(sourceConnection, graph) ?? sourceConnection.OutputSocket.Type; if (param.Value.ExtractElementType) { @@ -228,6 +233,98 @@ public static bool IsGenericSocket(SocketVisual socket) return false; } + /// + /// Recursively resolves the concrete type through chains of dynamic nodes + /// + private static Type ResolveTypeRecursively(NodeConnection connection, NodesGraph graph, HashSet visited = null) + { + if (connection == null || connection.OutputSocket == null) + return null; + + // Prevent infinite recursion + if (visited == null) + visited = new HashSet(); + if (visited.Contains(connection.OutputNode)) + return connection.OutputSocket.Type; + visited.Add(connection.OutputNode); + + // If the output socket has a runtime type set, use it + Type runtimeType = connection.OutputNode.GetSocketRuntimeType(connection.OutputSocketName); + if (runtimeType != null && runtimeType != typeof(object)) + { + return runtimeType; + } + + // If this is a dynamic node with generic output, trace back further + if (IsDynamicNode(connection.OutputNode) && IsGenericSocket(connection.OutputSocket)) + { + // Find the input connections to this node + Dictionary outputNodeDynamicParams = GetDynamicParameters(connection.OutputNode); + + // Look for the parameter that corresponds to this output + foreach (KeyValuePair param in outputNodeDynamicParams) + { + if (param.Key == connection.OutputSocketName) + { + // If this output derives from an input, trace that input + if (!string.IsNullOrEmpty(param.Value.DerivedFrom)) + { + NodeConnection upstreamConnection = graph.Connections.FirstOrDefault( + c => c.InputNode == connection.OutputNode && c.InputSocketName == param.Value.DerivedFrom); + + if (upstreamConnection != null) + { + Type upstreamType = ResolveTypeRecursively(upstreamConnection, graph, visited); + + // Apply transformations if needed + if (param.Value.ExtractElementType) + { + upstreamType = GetElementType(upstreamType); + } + else if (param.Value.WrapInCollection) + { + upstreamType = CreateTypedCollectionType(upstreamType, typeof(List)); + } + + return upstreamType; + } + } + // If it's part of a type group, find inputs with the same type group + else if (!string.IsNullOrEmpty(param.Value.TypeGroup)) + { + foreach (KeyValuePair inputParam in outputNodeDynamicParams) + { + if (inputParam.Value.TypeGroup == param.Value.TypeGroup && inputParam.Key != param.Key) + { + NodeConnection upstreamConnection = graph.Connections.FirstOrDefault( + c => c.InputNode == connection.OutputNode && c.InputSocketName == inputParam.Key); + + if (upstreamConnection != null) + { + Type upstreamType = ResolveTypeRecursively(upstreamConnection, graph, visited); + + // Apply transformations from the output parameter + if (param.Value.ExtractElementType) + { + upstreamType = GetElementType(upstreamType); + } + else if (param.Value.WrapInCollection) + { + upstreamType = CreateTypedCollectionType(upstreamType, typeof(List)); + } + + return upstreamType; + } + } + } + } + } + } + } + + return connection.OutputSocket.Type; + } + /// /// Gets the actual runtime type from a connection's source /// @@ -244,6 +341,13 @@ public static Type GetActualType(NodeConnection connection) return value.GetType(); } + // If the output socket has a runtime type set (from type propagation), use it + Type runtimeType = connection.OutputNode.GetSocketRuntimeType(connection.OutputSocketName); + if (runtimeType != null && runtimeType != typeof(object)) + { + return runtimeType; + } + return connection.OutputSocket.Type; } @@ -254,6 +358,11 @@ public static Type GetElementType(Type collectionType) { if (collectionType == null) return typeof(object); + if (collectionType.IsByRef) + { + collectionType = collectionType.GetElementType(); + } + // Handle arrays if (collectionType.IsArray) { @@ -296,28 +405,156 @@ public static Type CreateTypedCollectionType(Type elementType, Type templateType { if (elementType == null) elementType = typeof(object); - // If template is an array - if (templateType.IsArray) + if (elementType.IsByRef) { - return elementType.MakeArrayType(); + elementType = elementType.GetElementType(); } + + // Always use List for consistency + // This avoids array covariance issues and simplifies type handling + return typeof(List<>).MakeGenericType(elementType); + } + + /// + /// Converts a value to the expected type for method invocation or data flow + /// + public static object ConvertValue(object value, Type expectedType) + { + // If value is null, return null (or default for value types) + if (value == null) + { + return expectedType.IsValueType ? Activator.CreateInstance(expectedType) : null; + } + + Type actualType = value.GetType(); - // If template is a generic type - if (templateType.IsGenericType) + // If types already match, no conversion needed + if (expectedType.IsAssignableFrom(actualType)) { - Type genericDef = templateType.GetGenericTypeDefinition(); + return value; + } + + // Always convert arrays to Lists for consistency + if (actualType.IsArray && IsCollectionType(expectedType)) + { + // Convert array to List + Type elementType = actualType.GetElementType(); + Type listType = typeof(List<>).MakeGenericType(elementType); + System.Collections.IList list = (System.Collections.IList)Activator.CreateInstance(listType); - if (genericDef == typeof(IEnumerable<>) || - genericDef == typeof(IList<>) || - genericDef == typeof(List<>) || - genericDef == typeof(ICollection<>)) + foreach (object item in (Array)value) { - return genericDef.MakeGenericType(elementType); + list.Add(item); } + + // Now convert the List to the expected type if needed + return ConvertCollection(list, listType, expectedType); } - // Default to List - return typeof(List<>).MakeGenericType(elementType); + // Handle collection type conversions + if (IsCollectionType(expectedType) && IsCollectionType(actualType)) + { + return ConvertCollection(value, actualType, expectedType); + } + + // Try standard type conversion + try + { + if (expectedType.IsEnum && value is string stringValue) + { + return Enum.Parse(expectedType, stringValue); + } + + if (typeof(IConvertible).IsAssignableFrom(actualType) && typeof(IConvertible).IsAssignableFrom(expectedType)) + { + return Convert.ChangeType(value, expectedType); + } + } + catch + { + // Conversion failed, return original value and let caller handle it + } + + // If no conversion worked, return the original value + // The caller might still work with implicit conversions + return value; + } + + /// + /// Converts between collection types + /// + private static object ConvertCollection(object value, Type actualType, Type expectedType) + { + // Special handling for IEnumerable + if (expectedType.IsGenericType && expectedType.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + Type expectedElementType = expectedType.GetGenericArguments()[0]; + + // If expecting IEnumerable, we can return the original collection + // The framework will handle boxing during iteration + if (expectedElementType == typeof(object) && value is IEnumerable) + { + // Return the original collection to preserve type information + // Boxing will happen automatically during enumeration + return value; + } + + // Try to convert to the expected IEnumerable type + if (value is IEnumerable enumerable) + { + // Create a List of the expected element type + Type listType = typeof(List<>).MakeGenericType(expectedElementType); + System.Collections.IList list = (System.Collections.IList)Activator.CreateInstance(listType); + + foreach (object item in enumerable) + { + // Recursively convert each item if needed + object convertedItem = ConvertValue(item, expectedElementType); + list.Add(convertedItem); + } + + return list; + } + } + + // Convert arrays to Lists to maintain consistency + // Even if expectedType is an array, we return a List + if (expectedType.IsArray && value is IEnumerable) + { + Type elementType = expectedType.GetElementType(); + Type listType = typeof(List<>).MakeGenericType(elementType); + System.Collections.IList list = (System.Collections.IList)Activator.CreateInstance(listType); + + foreach (object item in (IEnumerable)value) + { + list.Add(ConvertValue(item, elementType)); + } + + // Return List instead of array - maintain List purity! + return list; + } + + // Handle List conversions + if (expectedType.IsGenericType && expectedType.GetGenericTypeDefinition() == typeof(List<>)) + { + Type expectedElementType = expectedType.GetGenericArguments()[0]; + Type listType = typeof(List<>).MakeGenericType(expectedElementType); + System.Collections.IList list = (System.Collections.IList)Activator.CreateInstance(listType); + + if (value is IEnumerable enumerable) + { + foreach (object item in enumerable) + { + object convertedItem = ConvertValue(item, expectedElementType); + list.Add(convertedItem); + } + } + + return list; + } + + // If we can't convert, return the original value + return value; } /// @@ -344,27 +581,55 @@ public static bool AreTypesCompatible(Type sourceType, Type targetType) Type targetElement = GetElementType(targetType); // Allow connection if element types are compatible - return targetElement == typeof(object) || - targetElement.IsAssignableFrom(sourceElement); + // This includes object accepting any type (including value types that can be boxed) + if (targetElement == typeof(object)) + { + return true; // Any type can be boxed to object + } + + // Check standard type compatibility + return targetElement.IsAssignableFrom(sourceElement) || + sourceElement.IsSubclassOf(targetElement) || + (targetElement.IsInterface && targetElement.IsAssignableFrom(sourceElement)); + } + + // Also allow connecting a collection to IEnumerable or IEnumerable + if (IsCollectionType(sourceType) && + (targetType == typeof(IEnumerable) || + (targetType.IsGenericType && targetType.GetGenericTypeDefinition() == typeof(IEnumerable<>)))) + { + if (targetType == typeof(IEnumerable)) + return true; + + Type sourceElement = GetElementType(sourceType); + Type targetElement = targetType.GetGenericArguments()[0]; + + // Allow any type to be converted to object (including value types via boxing) + if (targetElement == typeof(object)) + return true; + + return targetElement.IsAssignableFrom(sourceElement); } return false; } - private static bool IsCollectionType(Type type) + public static bool IsCollectionType(Type type) { - if (type.IsArray) return true; - if (type == typeof(IEnumerable)) return true; - + // Simplified - we primarily care about Lists now if (type.IsGenericType) { Type genericDef = type.GetGenericTypeDefinition(); - return genericDef == typeof(IEnumerable<>) || + return genericDef == typeof(List<>) || genericDef == typeof(IList<>) || - genericDef == typeof(List<>) || + genericDef == typeof(IEnumerable<>) || genericDef == typeof(ICollection<>); } + // Still support arrays for backward compatibility + if (type.IsArray) return true; + if (type == typeof(IEnumerable)) return true; + return typeof(IEnumerable).IsAssignableFrom(type); } } diff --git a/SampleCommon/ControlNodeEditor.Designer.cs b/SampleCommon/ControlNodeEditor.Designer.cs index 91f7a0e..302a97b 100644 --- a/SampleCommon/ControlNodeEditor.Designer.cs +++ b/SampleCommon/ControlNodeEditor.Designer.cs @@ -30,10 +30,10 @@ private void InitializeComponent() { this.splitContainer1 = new System.Windows.Forms.SplitContainer(); this.panel = new System.Windows.Forms.Panel(); - this.nodesControl = new NodeEditor.NodesControl(); this.splitContainer2 = new System.Windows.Forms.SplitContainer(); this.propertyGrid = new System.Windows.Forms.PropertyGrid(); this.buttonProcess = new System.Windows.Forms.Button(); + this.nodesControl = new NodeEditor.NodesControl(); ((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).BeginInit(); this.splitContainer1.Panel1.SuspendLayout(); this.splitContainer1.Panel2.SuspendLayout(); @@ -59,7 +59,7 @@ private void InitializeComponent() // this.splitContainer1.Panel2.Controls.Add(this.splitContainer2); this.splitContainer1.Size = new System.Drawing.Size(622, 485); - this.splitContainer1.SplitterDistance = 378; + this.splitContainer1.SplitterDistance = 465; this.splitContainer1.TabIndex = 0; // // panel @@ -69,18 +69,9 @@ private void InitializeComponent() this.panel.Dock = System.Windows.Forms.DockStyle.Fill; this.panel.Location = new System.Drawing.Point(0, 0); this.panel.Name = "panel"; - this.panel.Size = new System.Drawing.Size(378, 485); + this.panel.Size = new System.Drawing.Size(465, 485); this.panel.TabIndex = 0; // - // nodesControl - // - this.nodesControl.BackgroundImage = global::SampleCommon.Properties.Resources.grid; - this.nodesControl.Context = null; - this.nodesControl.Location = new System.Drawing.Point(0, 0); - this.nodesControl.Name = "nodesControl"; - this.nodesControl.Size = new System.Drawing.Size(5000, 5000); - this.nodesControl.TabIndex = 0; - // // splitContainer2 // this.splitContainer2.Dock = System.Windows.Forms.DockStyle.Fill; @@ -96,8 +87,8 @@ private void InitializeComponent() // splitContainer2.Panel2 // this.splitContainer2.Panel2.Controls.Add(this.buttonProcess); - this.splitContainer2.Size = new System.Drawing.Size(240, 485); - this.splitContainer2.SplitterDistance = 336; + this.splitContainer2.Size = new System.Drawing.Size(153, 485); + this.splitContainer2.SplitterDistance = 393; this.splitContainer2.TabIndex = 0; // // propertyGrid @@ -105,7 +96,7 @@ private void InitializeComponent() this.propertyGrid.Dock = System.Windows.Forms.DockStyle.Fill; this.propertyGrid.Location = new System.Drawing.Point(0, 0); this.propertyGrid.Name = "propertyGrid"; - this.propertyGrid.Size = new System.Drawing.Size(240, 336); + this.propertyGrid.Size = new System.Drawing.Size(153, 393); this.propertyGrid.TabIndex = 1; // // buttonProcess @@ -113,12 +104,22 @@ private void InitializeComponent() this.buttonProcess.Dock = System.Windows.Forms.DockStyle.Top; this.buttonProcess.Location = new System.Drawing.Point(0, 0); this.buttonProcess.Name = "buttonProcess"; - this.buttonProcess.Size = new System.Drawing.Size(240, 23); + this.buttonProcess.Size = new System.Drawing.Size(153, 23); this.buttonProcess.TabIndex = 0; - this.buttonProcess.Text = "Process"; + this.buttonProcess.Text = "Calculate"; this.buttonProcess.UseVisualStyleBackColor = true; this.buttonProcess.Click += new System.EventHandler(this.buttonProcess_Click); // + // nodesControl + // + this.nodesControl.BackgroundImage = global::SampleCommon.Properties.Resources.grid; + this.nodesControl.Context = null; + this.nodesControl.Dock = System.Windows.Forms.DockStyle.Fill; + this.nodesControl.Location = new System.Drawing.Point(0, 0); + this.nodesControl.Name = "nodesControl"; + this.nodesControl.Size = new System.Drawing.Size(465, 485); + this.nodesControl.TabIndex = 0; + // // ControlNodeEditor // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); From e36a66432628b1f1b14daf7aebf66d9276bbf6f9 Mon Sep 17 00:00:00 2001 From: Brian Whitenack Date: Sat, 13 Sep 2025 21:28:37 -0500 Subject: [PATCH 5/8] feat: UX --- MathSample/FormMathSample.Designer.cs | 58 +++- MathSample/FormMathSample.cs | 50 ++++ MathSample/FormMathSample.resx | 15 ++ MathSample/PartCalculation.cs | 66 ++++- NodeEditor/NodeVisual.cs | 172 +++++++++++- NodeEditor/NodesControl.cs | 292 +++++++++++++++++---- NodeEditor/SocketVisual.cs | 60 ++++- SampleCommon/ControlNodeEditor.Designer.cs | 22 +- 8 files changed, 637 insertions(+), 98 deletions(-) diff --git a/MathSample/FormMathSample.Designer.cs b/MathSample/FormMathSample.Designer.cs index fedb69b..665bc08 100644 --- a/MathSample/FormMathSample.Designer.cs +++ b/MathSample/FormMathSample.Designer.cs @@ -36,9 +36,11 @@ private void InitializeComponent() this.splitContainer1 = new System.Windows.Forms.SplitContainer(); this.tabControl1 = new System.Windows.Forms.TabControl(); this.tabMeasurements = new System.Windows.Forms.TabPage(); - this.tabParts = new System.Windows.Forms.TabPage(); this.txtMeasurements = new System.Windows.Forms.TextBox(); + this.tabParts = new System.Windows.Forms.TabPage(); this.txtParts = new System.Windows.Forms.TextBox(); + this.btnNew = new System.Windows.Forms.ToolStripButton(); + this.btnUpdateMeasurements = new System.Windows.Forms.Button(); this.toolStrip1.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).BeginInit(); this.splitContainer1.Panel1.SuspendLayout(); @@ -53,7 +55,8 @@ private void InitializeComponent() // this.toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { this.btnSave, - this.btnLoad}); + this.btnLoad, + this.btnNew}); this.toolStrip1.Location = new System.Drawing.Point(0, 0); this.toolStrip1.Name = "toolStrip1"; this.toolStrip1.Size = new System.Drawing.Size(957, 25); @@ -118,6 +121,7 @@ private void InitializeComponent() // // tabMeasurements // + this.tabMeasurements.Controls.Add(this.btnUpdateMeasurements); this.tabMeasurements.Controls.Add(this.txtMeasurements); this.tabMeasurements.Location = new System.Drawing.Point(4, 22); this.tabMeasurements.Name = "tabMeasurements"; @@ -127,36 +131,60 @@ private void InitializeComponent() this.tabMeasurements.Text = "Measurements"; this.tabMeasurements.UseVisualStyleBackColor = true; // + // txtMeasurements + // + this.txtMeasurements.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.txtMeasurements.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.txtMeasurements.Location = new System.Drawing.Point(3, 32); + this.txtMeasurements.Multiline = true; + this.txtMeasurements.Name = "txtMeasurements"; + this.txtMeasurements.Size = new System.Drawing.Size(134, 424); + this.txtMeasurements.TabIndex = 0; + // // tabParts // this.tabParts.Controls.Add(this.txtParts); this.tabParts.Location = new System.Drawing.Point(4, 22); this.tabParts.Name = "tabParts"; this.tabParts.Padding = new System.Windows.Forms.Padding(3); - this.tabParts.Size = new System.Drawing.Size(311, 459); + this.tabParts.Size = new System.Drawing.Size(140, 459); this.tabParts.TabIndex = 1; this.tabParts.Text = "Parts"; this.tabParts.UseVisualStyleBackColor = true; // - // txtMeasurements - // - this.txtMeasurements.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; - this.txtMeasurements.Dock = System.Windows.Forms.DockStyle.Fill; - this.txtMeasurements.Location = new System.Drawing.Point(3, 3); - this.txtMeasurements.Multiline = true; - this.txtMeasurements.Name = "txtMeasurements"; - this.txtMeasurements.Size = new System.Drawing.Size(134, 453); - this.txtMeasurements.TabIndex = 0; - // // txtParts // this.txtParts.Dock = System.Windows.Forms.DockStyle.Fill; this.txtParts.Location = new System.Drawing.Point(3, 3); this.txtParts.Multiline = true; this.txtParts.Name = "txtParts"; - this.txtParts.Size = new System.Drawing.Size(305, 453); + this.txtParts.Size = new System.Drawing.Size(134, 453); this.txtParts.TabIndex = 0; // + // btnNew + // + this.btnNew.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnNew.Image = ((System.Drawing.Image)(resources.GetObject("btnNew.Image"))); + this.btnNew.ImageTransparentColor = System.Drawing.Color.Magenta; + this.btnNew.Name = "btnNew"; + this.btnNew.Size = new System.Drawing.Size(35, 22); + this.btnNew.Text = "New"; + this.btnNew.Click += new System.EventHandler(this.btnNew_Click); + // + // btnUpdateMeasurements + // + this.btnUpdateMeasurements.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.btnUpdateMeasurements.Location = new System.Drawing.Point(3, 6); + this.btnUpdateMeasurements.Name = "btnUpdateMeasurements"; + this.btnUpdateMeasurements.Size = new System.Drawing.Size(134, 23); + this.btnUpdateMeasurements.TabIndex = 2; + this.btnUpdateMeasurements.Text = "Update"; + this.btnUpdateMeasurements.UseVisualStyleBackColor = true; + this.btnUpdateMeasurements.Click += new System.EventHandler(this.btnUpdateMeasurements_Click); + // // FormMathSample // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); @@ -195,6 +223,8 @@ private void InitializeComponent() private System.Windows.Forms.TabPage tabParts; private System.Windows.Forms.TextBox txtMeasurements; private System.Windows.Forms.TextBox txtParts; + private System.Windows.Forms.ToolStripButton btnNew; + private System.Windows.Forms.Button btnUpdateMeasurements; } } diff --git a/MathSample/FormMathSample.cs b/MathSample/FormMathSample.cs index 28fbcb6..7059f00 100644 --- a/MathSample/FormMathSample.cs +++ b/MathSample/FormMathSample.cs @@ -33,9 +33,45 @@ public FormMathSample() { "BeamType", "Header" }, { "Material", "LVL" }, { "Grade", "#1" }, + { "Plies", "3" }, { "Thickness", 2 }, { "Width", 4 } } + }, + new Measurement() + { + Type = "Beam", + Length = 14.5, + Count = 2, + Selections = new Dictionary() + { + { "BeamType", "Header" }, + { "Material", "LVL" }, + { "Grade", "#1" }, + { "Plies", "1" }, + { "Thickness", 2 }, + { "Width", 4 } + } + }, + new Measurement() + { + Type = "Siding", + Area = 1200, + Count = 2, + Selections = new Dictionary() + { + { "Selection", "Brick" }, + } + }, + new Measurement() + { + Type = "Siding", + Area = 100, + Count = 2, + Selections = new Dictionary() + { + { "Selection", "LP" }, + } } }; @@ -140,5 +176,19 @@ private void AddDefaultNodes() // Add Parts List node by method name controlNodeEditor.nodesControl.AddNodeByMethodName("PartsList", 300, 50); } + + private void btnNew_Click(object sender, EventArgs e) + { + // Clear the current node graph + controlNodeEditor.nodesControl.Clear(); + + // Add default nodes for a new graph + AddDefaultNodes(); + } + + private void btnUpdateMeasurements_Click(object sender, EventArgs e) + { + context.Measurements = JsonConvert.DeserializeObject>(txtMeasurements.Text); + } } } diff --git a/MathSample/FormMathSample.resx b/MathSample/FormMathSample.resx index 9f0b17f..2dff882 100644 --- a/MathSample/FormMathSample.resx +++ b/MathSample/FormMathSample.resx @@ -149,6 +149,21 @@ HBUzHot52djqQ6HZhfR7IwK4mKpHtvEDMqvfCiQ6zaAAXM8x94aIWTNrLLG4kVUzgaTSPlzLtyJOZxbb 1wtfyg4Q+AfA3aZlButjSfxGcUJBk4g5tuP3haQKRKXcUQDOmbvNTpPOJeFFjordZmbWTNvMTHFUcpUC nOccAdABIDXXE1nzAAAAAElFTkSuQmCC + + + + + iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8 + YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAIFSURBVDhPpZLtS1NhGMbPPxJmmlYSgqHiKzGU1EDxg4iK + YKyG2WBogqMYJQOtCEVRFBGdTBCJfRnkS4VaaWNT5sqx1BUxRXxDHYxAJLvkusEeBaPAB+5z4Jzn+t3X + /aLhnEfjo8m+dCoa+7/C3O2Hqe0zDC+8KG+cRZHZhdzaaWTVTCLDMIY0vfM04Nfh77/G/sEhwpEDbO3t + I7TxE8urEVy99fT/AL5gWDLrTB/hnF4XsW0khCu5ln8DmJliT2AXrcNBsU1gj/MH4nMeKwBrPktM28xM + cX79DFKrHHD5d9D26hvicx4pABt2lpg10zYzU0zr7+e3xXGcrkEB2O2TNec9nJFwB3alZn5jZorfeDZh + 6Q3g8s06BeCoKF4MRURoH1+BY2oNCbeb0TIclIYxOhzf8frTOuo7FxCbbVIAzpni0iceEc8vhzEwGkJD + lx83ymxifejdKjRNk/8PWnyIyTQqAJek0jqHwfEVscu31baIu8+90sTE4nY025dQ2/5FIPpnXlzKuK8A + HBUzHot52djqQ6HZhfR7IwK4mKpHtvEDMqvfCiQ6zaAAXM8x94aIWTNrLLG4kVUzgaTSPlzLtyJOZxbb + 1wtfyg4Q+AfA3aZlButjSfxGcUJBk4g5tuP3haQKRKXcUQDOmbvNTpPOJeFFjordZmbWTNvMTHFUcpUC + nOccAdABIDXXE1nzAAAAAElFTkSuQmCC \ No newline at end of file diff --git a/MathSample/PartCalculation.cs b/MathSample/PartCalculation.cs index fe10574..5280b57 100644 --- a/MathSample/PartCalculation.cs +++ b/MathSample/PartCalculation.cs @@ -14,6 +14,12 @@ public class PartCalculation : INodesContext { public event Action OnExecutionFinished; + public enum UnitOfMeasure + { + EA, + SQFT, + } + public NodeVisual CurrentProcessingNode { get; set; } public event Action FeedbackInfo; @@ -31,6 +37,12 @@ public void FinishExecution() OnExecutionFinished?.Invoke(); } + [Node("Filter To Type", "Measurements", "Basic", "Filter measurements by type", false)] + public void FilterToType(List measurements, string type, out List filteredMeasurements) + { + filteredMeasurements = measurements.Where(m => m.Type.Equals(type, StringComparison.OrdinalIgnoreCase)).ToList(); + } + [Node("Measurement List", "Measurements", "Basic", "Get the current measurement list", false)] public void MeasurementList(out List measurements) { @@ -69,6 +81,24 @@ public void MeasurementLength(Measurement measurement, out double length) length = measurement.Length; } + [Node("Measurement Length Sum", "Measurements", "Basic", "Get the length of the measurement", false)] + public void MeasurementLengthSum(List measurements, out double length) + { + length = measurements.Sum(m => m.Length); + } + + [Node("Measurement Area", "Measurements", "Basic", "Get the area of the measurement", false)] + public void MeasurementArea(Measurement measurement, out double area) + { + area = measurement.Area; + } + + [Node("Measurement Area Sum", "Measurements", "Basic", "Get the area of the measurement", false)] + public void MeasurementAreaSum(List measurements, out double area) + { + area = measurements.Sum(m => m.Area); + } + [Node("If Else", "Flow Control", "Basic", "Standard If Else flow control", false, flowControlHandler: typeof(IfElseFlowControl))] public void IfElse(bool condition, ExecutionPath enter, out ExecutionPath ifTrue, out ExecutionPath ifFalse) { @@ -83,7 +113,7 @@ public void BooleanValue(bool inValue, out bool outValue) } [Node("Create Part", "Parts", "Basic", "Create a part")] - public void CreatePart(string sku, string description, string package, float quantity, string unitOfMeasure, out Part part) + public void CreatePart(string sku, string description, string package, double quantity, string unitOfMeasure, out Part part) { part = new Part { @@ -99,6 +129,7 @@ public void CreatePart(string sku, string description, string package, float qua public void PartsList(ExecutionPath calculationEnd, List parts) { Parts = parts; + FinishExecution(); } [Node("String Value", "Constants", "Basic", "Allows to output a simple string value.", false)] @@ -129,7 +160,7 @@ public void ForEach( forEachResult = null; } - [Node("Pass Through", "Flow Control", "Functional", "Pass through a variable with control flow.", true)] + [Node("Pass Through Value", "Flow Control", "Functional", "Pass through a variable with control flow.", true)] [DynamicNode] public void PassThrough( [DynamicType(TypeGroup = "Input")] object input, @@ -138,12 +169,31 @@ public void PassThrough( output = input; } - [Node("Value", "Constants", "Basic", "Allows to output a simple value.", false)] + [Node("Number Value", "Constants", "Basic", "Allows to output a simple value.", false)] public void InputValue(double inValue, out double outValue) { outValue = inValue; } + [Node("TXWXL", "Dimensions", "Basic", "Creates a TXWXL string", false)] + [DynamicNode] + public void ThicknessWidthLength( + [DynamicType(TypeGroup = "Thickness")] object thickness, + [DynamicType(TypeGroup = "Width")] object width, + [DynamicType(TypeGroup = "Length")] object length, out string dimension) + { + dimension = $"{thickness}X{width}X{length}"; + } + + [Node("Round", "Math", "Basic", "Rounds a number to the nearest integer.", false)] + public void Round(double value, bool toNearestEven, out double result) + { + result = Math.Ceiling(value); + + if (result % 2 != 0) + result += 1; + } + [Node("Add", "Math", "Basic", "Adds two input values.", false)] public void Add(double a, double b, out double result) { @@ -157,13 +207,13 @@ public void Subtract(double a, double b, out double result) } [Node("Multiply", "Math", "Basic", "Multiplies two input values.", false)] - public void Multiply(float a, double b, out double result) + public void Multiply(double a, double b, out double result) { result = a * b; } [Node("Divide", "Math", "Basic", "Divides two input values.", false)] - public void Divide(float a, double b, out double result) + public void Divide(double a, double b, out double result) { result = a / b; } @@ -201,6 +251,12 @@ public void Concatenate(string a, string b, out string result) result = a + b; } + [Node("Join", "Operators", "String", "Joins two strings with a separator.", false)] + public void Join(string separator, string a, string b, out string result) + { + result = string.Join(separator, a, b); + } + [Node("To String", "Operators", "String", "Converts to a string.", false)] [DynamicNode] public void ToStringNode([DynamicType(TypeGroup = "Input")] object a, out string result) diff --git a/NodeEditor/NodeVisual.cs b/NodeEditor/NodeVisual.cs index 723d2dd..7da1330 100644 --- a/NodeEditor/NodeVisual.cs +++ b/NodeEditor/NodeVisual.cs @@ -193,6 +193,21 @@ internal void DiscardCache() socketCache = null; } + /// + /// Updates socket positions when the node moves without recalculating everything + /// + internal void UpdateSocketPositions(float dx, float dy) + { + if (socketCache != null) + { + foreach (var socket in socketCache) + { + socket.X += dx; + socket.Y += dy; + } + } + } + /// /// Returns node context which is dynamic type. It will contain all node default input/output properties. /// @@ -340,40 +355,171 @@ internal ParameterInfo[] GetOutputs() return Type.GetParameters().Where(x => x.IsOut).ToArray(); } + /// + /// Converts a camelCase or PascalCase string to Title Case with spaces + /// + private string ToTitleCase(string name) + { + if (string.IsNullOrEmpty(name)) + return name; + + // Insert spaces before uppercase letters (except the first one) + var result = new StringBuilder(); + for (int i = 0; i < name.Length; i++) + { + if (i > 0 && char.IsUpper(name[i]) && !char.IsUpper(name[i - 1])) + { + result.Append(' '); + } + result.Append(name[i]); + } + + // Ensure first letter is uppercase + if (result.Length > 0 && char.IsLower(result[0])) + { + result[0] = char.ToUpper(result[0]); + } + + return result.ToString(); + } + + /// + /// Calculates the automatic width based on socket names + /// + private float CalculateAutoWidth() + { + float minWidth = NodeWidth; // Minimum width + float padding = 20; // Padding between input and output names + float socketPadding = 4; // Padding from socket to text + float edgePadding = 5; // Padding from edge of node + + // If we have cached sockets, use their cached display names + if (socketCache != null) + { + float maxInputWidth = 0; + float maxOutputWidth = 0; + + foreach (var socket in socketCache) + { + SizeF textSize = TextRenderer.MeasureText(socket.DisplayName, SystemFonts.SmallCaptionFont); + if (socket.Input) + { + maxInputWidth = Math.Max(maxInputWidth, textSize.Width); + } + else + { + maxOutputWidth = Math.Max(maxOutputWidth, textSize.Width); + } + } + + // Calculate width for node name + SizeF nameSize = TextRenderer.MeasureText(Name, SystemFonts.DefaultFont); + float nameWidth = nameSize.Width + 10; // Add some padding for the name + + // Calculate total width needed + float socketBasedWidth = SocketVisual.SocketHeight + socketPadding + maxInputWidth + padding + + maxOutputWidth + socketPadding + SocketVisual.SocketHeight + edgePadding * 2; + + // Return the maximum of minimum width, name width, and socket-based width + return Math.Max(Math.Max(minWidth, nameWidth), socketBasedWidth); + } + else + { + // Fallback: calculate without caching (initial calculation) + float maxInputWidth = 0; + ParameterInfo[] inputs = GetInputs(); + foreach (ParameterInfo input in inputs) + { + string displayName = ToTitleCase(input.Name); + SizeF textSize = TextRenderer.MeasureText(displayName, SystemFonts.SmallCaptionFont); + maxInputWidth = Math.Max(maxInputWidth, textSize.Width); + } + + // Add execution input if callable + if (Callable && !ExecInit) + { + SizeF textSize = TextRenderer.MeasureText("Enter", SystemFonts.SmallCaptionFont); + maxInputWidth = Math.Max(maxInputWidth, textSize.Width); + } + + // Calculate maximum width needed for output socket names + float maxOutputWidth = 0; + ParameterInfo[] outputs = GetOutputs(); + foreach (ParameterInfo output in outputs) + { + string displayName = ToTitleCase(output.Name); + SizeF textSize = TextRenderer.MeasureText(displayName, SystemFonts.SmallCaptionFont); + maxOutputWidth = Math.Max(maxOutputWidth, textSize.Width); + } + + // Add execution output if callable + if (Callable) + { + SizeF textSize = TextRenderer.MeasureText("Exit", SystemFonts.SmallCaptionFont); + maxOutputWidth = Math.Max(maxOutputWidth, textSize.Width); + } + + // Calculate width for node name + SizeF nameSize = TextRenderer.MeasureText(Name, SystemFonts.DefaultFont); + float nameWidth = nameSize.Width + 10; // Add some padding for the name + + // Calculate total width needed + // Socket width + socket padding + text width + center padding + text width + socket padding + socket width + float socketBasedWidth = SocketVisual.SocketHeight + socketPadding + maxInputWidth + padding + + maxOutputWidth + socketPadding + SocketVisual.SocketHeight + edgePadding * 2; + + // Return the maximum of minimum width, name width, and socket-based width + return Math.Max(Math.Max(minWidth, nameWidth), socketBasedWidth); + } + } + /// /// Returns current size of the node. - /// + /// public SizeF GetNodeBounds() { - var csize = new SizeF(); + SizeF csize = new SizeF(); if (CustomEditor != null) { csize = new SizeF(CustomEditor.ClientSize.Width + 2 + 80 +SocketVisual.SocketHeight*2, - CustomEditor.ClientSize.Height + HeaderHeight + 8); + CustomEditor.ClientSize.Height + HeaderHeight + 8); } - var inputs = GetInputs().Length; - var outputs = GetOutputs().Length; + int inputs = GetInputs().Length; + int outputs = GetOutputs().Length; if (Callable) { inputs++; outputs++; } - var h = HeaderHeight + Math.Max(inputs*(SocketVisual.SocketHeight + ComponentPadding), + float h = HeaderHeight + Math.Max(inputs*(SocketVisual.SocketHeight + ComponentPadding), outputs*(SocketVisual.SocketHeight + ComponentPadding)) + ComponentPadding*2f; - csize.Width = Math.Max(csize.Width, NodeWidth); - csize.Height = Math.Max(csize.Height, h); - if(CustomWidth >= 0) + // Use automatic width if no custom width is specified + float width; + if (CustomWidth >= 0) + { + width = CustomWidth; + } + else + { + // Calculate automatic width based on socket names + float autoWidth = CalculateAutoWidth(); + width = Math.Max(csize.Width, autoWidth); + } + + // Use custom height if specified, otherwise use calculated height + float height; + if (CustomHeight >= 0) { - csize.Width = CustomWidth; + height = CustomHeight; } - if(CustomHeight >= 0) + else { - csize.Height = CustomHeight; + height = Math.Max(csize.Height, h); } - return new SizeF(csize.Width, csize.Height); + return new SizeF(width, height); } /// diff --git a/NodeEditor/NodesControl.cs b/NodeEditor/NodesControl.cs index 6d49541..b37ce66 100644 --- a/NodeEditor/NodesControl.cs +++ b/NodeEditor/NodesControl.cs @@ -111,6 +111,18 @@ public INodesContext Context private bool breakExecution = false; + // Panning support + private bool isPanning = false; + private Point panStartPoint; + private PointF panOffset = new PointF(0, 0); + private bool rightMouseMoved = false; + + // Zoom support + private float zoomLevel = 1.0f; + private const float MIN_ZOOM = 0.1f; + private const float MAX_ZOOM = 3.0f; + private const float ZOOM_STEP = 0.1f; + /// /// Default constructor /// @@ -121,7 +133,10 @@ public NodesControl() timer.Tick += TimerOnTick; timer.Start(); KeyDown += OnKeyDown; + MouseWheel += OnMouseWheel; SetStyle(ControlStyles.Selectable, true); + // Enable double buffering for smoother rendering + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); } private void ContextOnFeedbackInfo(string message, NodeVisual nodeVisual, FeedbackType type, object tag, bool breakExecution) @@ -151,6 +166,49 @@ private void OnKeyDown(object sender, KeyEventArgs keyEventArgs) } } + private void OnMouseWheel(object sender, MouseEventArgs e) + { + // Store the old zoom level + float oldZoom = zoomLevel; + + // Calculate new zoom level + float zoomDelta = e.Delta > 0 ? ZOOM_STEP : -ZOOM_STEP; + float newZoom = oldZoom + zoomDelta; + zoomLevel = Math.Max(MIN_ZOOM, Math.Min(MAX_ZOOM, newZoom)); + + // Calculate zoom ratio + float zoomRatio = zoomLevel / oldZoom; + + // Adjust pan offset to keep mouse position fixed in world space + // The point under the mouse should remain at the same screen position + panOffset.X = e.Location.X - (e.Location.X - panOffset.X) * zoomRatio; + panOffset.Y = e.Location.Y - (e.Location.Y - panOffset.Y) * zoomRatio; + + needRepaint = true; + } + + /// + /// Convert screen coordinates to world coordinates (considering zoom and pan) + /// + private PointF ScreenToWorld(Point screenPoint) + { + return new PointF( + (screenPoint.X - panOffset.X) / zoomLevel, + (screenPoint.Y - panOffset.Y) / zoomLevel + ); + } + + /// + /// Convert world coordinates to screen coordinates (considering zoom and pan) + /// + private PointF WorldToScreen(PointF worldPoint) + { + return new PointF( + worldPoint.X * zoomLevel + panOffset.X, + worldPoint.Y * zoomLevel + panOffset.Y + ); + } + private void TimerOnTick(object sender, EventArgs eventArgs) { if (DesignMode) return; @@ -160,19 +218,71 @@ private void TimerOnTick(object sender, EventArgs eventArgs) } } + private void DrawGrid(Graphics g) + { + // Grid settings + int baseGridSize = 20; + float gridSize = baseGridSize * zoomLevel; // Scale grid with zoom + Color gridColor = Color.FromArgb(60, 0, 0, 0); // Light gray grid + Color majorGridColor = Color.FromArgb(100, 0, 0, 0); // Darker for major lines + + using (Pen gridPen = new Pen(gridColor, 1)) + using (Pen majorGridPen = new Pen(majorGridColor, 1)) + { + // Calculate grid offset based on pan and zoom + float offsetX = panOffset.X % gridSize; + float offsetY = panOffset.Y % gridSize; + + // Draw vertical lines + for (float x = offsetX; x < Width; x += gridSize) + { + // Every 5th line is a major grid line + int gridIndex = (int)Math.Round((x - panOffset.X) / gridSize); + bool isMajor = gridIndex % 5 == 0; + g.DrawLine(isMajor ? majorGridPen : gridPen, x, 0, x, Height); + } + + // Draw horizontal lines + for (float y = offsetY; y < Height; y += gridSize) + { + // Every 5th line is a major grid line + int gridIndex = (int)Math.Round((y - panOffset.Y) / gridSize); + bool isMajor = gridIndex % 5 == 0; + g.DrawLine(isMajor ? majorGridPen : gridPen, 0, y, Width, y); + } + } + } + private void NodesControl_Paint(object sender, PaintEventArgs e) { e.Graphics.SmoothingMode = SmoothingMode.HighQuality; e.Graphics.InterpolationMode = InterpolationMode.HighQualityBilinear; - graph.Draw(e.Graphics, PointToClient(MousePosition), MouseButtons); + // Draw the background grid first (not transformed) + DrawGrid(e.Graphics); + + // Save the original transform + var originalTransform = e.Graphics.Transform; + + // Apply zoom and pan transformation + e.Graphics.TranslateTransform(panOffset.X, panOffset.Y); + e.Graphics.ScaleTransform(zoomLevel, zoomLevel); + + // Transform mouse position for drawing + PointF transformedMouse = ScreenToWorld(PointToClient(MousePosition)); + + graph.Draw(e.Graphics, Point.Round(transformedMouse), MouseButtons); if (dragSocket != null) { - Pen pen = new Pen(Color.Black, 2); + Pen pen = new Pen(Color.Black, 2 / zoomLevel); // Adjust pen width for zoom + // dragConnectionBegin and dragConnectionEnd are already in world space NodesGraph.DrawConnection(e.Graphics, pen, dragConnectionBegin, dragConnectionEnd); } + // Restore transform for UI elements + e.Graphics.Transform = originalTransform; + if (selectionStart != PointF.Empty) { Rectangle rect = Rectangle.Round(MakeRect(selectionStart, selectionEnd)); @@ -195,6 +305,22 @@ private static RectangleF MakeRect(PointF a, PointF b) private void NodesControl_MouseMove(object sender, MouseEventArgs e) { Point em = PointToScreen(e.Location); + + // Handle right-click panning + if (isPanning && e.Button == MouseButtons.Right) + { + rightMouseMoved = true; + float dx = e.Location.X - panStartPoint.X; + float dy = e.Location.Y - panStartPoint.Y; + + panOffset.X += dx; + panOffset.Y += dy; + + panStartPoint = e.Location; + needRepaint = true; + return; + } + if (selectionStart != PointF.Empty) { selectionEnd = e.Location; @@ -204,24 +330,44 @@ private void NodesControl_MouseMove(object sender, MouseEventArgs e) if (!isDraggingConnection && dragSocket == null) { // Regular node dragging - only when not dragging sockets or connections - foreach (NodeVisual node in graph.Nodes.Where(x => x.IsSelected)) - { - node.X += em.X - lastmpos.X; - node.Y += em.Y - lastmpos.Y; - node.DiscardCache(); - node.LayoutEditor(); - } - if (graph.Nodes.Exists(x => x.IsSelected)) + // Scale the movement by zoom level + float dx = (em.X - lastmpos.X) / zoomLevel; + float dy = (em.Y - lastmpos.Y) / zoomLevel; + + // Cache selected nodes to avoid multiple LINQ queries + var selectedNodes = graph.Nodes.Where(x => x.IsSelected).ToList(); + + if (selectedNodes.Count > 0) { - NodeVisual n = graph.Nodes.FirstOrDefault(x => x.IsSelected); - RectangleF bound = new RectangleF(new PointF(n.X, n.Y), n.GetNodeBounds()); - foreach (NodeVisual node in graph.Nodes.Where(x => x.IsSelected)) + // Move all selected nodes + foreach (NodeVisual node in selectedNodes) { - bound = RectangleF.Union(bound, new RectangleF(new PointF(node.X, node.Y), node.GetNodeBounds())); + node.X += dx; + node.Y += dy; + // Efficiently update socket positions without full recalculation + node.UpdateSocketPositions(dx, dy); + // Only layout custom editors if present + if (node.CustomEditor != null) + { + node.LayoutEditor(); + } } - OnShowLocation(bound); - } + // Calculate bounds only if needed for auto-scroll + // This is expensive so we can skip it for most drag operations + // Only calculate every few pixels of movement + if (Math.Abs(dx) > 5 || Math.Abs(dy) > 5) + { + NodeVisual firstNode = selectedNodes[0]; + RectangleF bound = new RectangleF(new PointF(firstNode.X, firstNode.Y), firstNode.GetNodeBounds()); + for (int i = 1; i < selectedNodes.Count; i++) + { + NodeVisual node = selectedNodes[i]; + bound = RectangleF.Union(bound, new RectangleF(new PointF(node.X, node.Y), node.GetNodeBounds())); + } + OnShowLocation(bound); + } + } } if (dragSocket != null) @@ -229,43 +375,40 @@ private void NodesControl_MouseMove(object sender, MouseEventArgs e) if (isDraggingConnection) { // Connection dragging: floating end follows mouse, fixed end stays at socket - PointF mousePos = PointToClient(em); + // Keep positions in world space + PointF mousePos = ScreenToWorld(PointToClient(em)); - // Get the fixed socket center (the one we're NOT dragging from) + // Get the fixed socket center (the one we're NOT dragging from) in world space PointF fixedSocketCenter = new PointF(dragSocket.X + dragSocket.Width / 2f, dragSocket.Y + dragSocket.Height / 2f); if (dragSocket.Input) { // Dragging towards a new input, keep current input socket fixed - dragConnectionBegin = mousePos; // Floating end follows mouse - dragConnectionEnd = fixedSocketCenter; // Fixed end stays at input socket - OnShowLocation(new RectangleF(dragConnectionBegin, new SizeF(10, 10))); + dragConnectionBegin = mousePos; // Floating end follows mouse (world space) + dragConnectionEnd = fixedSocketCenter; // Fixed end stays at input socket (world space) } else { // Dragging towards a new output, keep current output socket fixed - dragConnectionBegin = fixedSocketCenter; // Fixed end stays at output socket - dragConnectionEnd = mousePos; // Floating end follows mouse - OnShowLocation(new RectangleF(dragConnectionEnd, new SizeF(10, 10))); + dragConnectionBegin = fixedSocketCenter; // Fixed end stays at output socket (world space) + dragConnectionEnd = mousePos; // Floating end follows mouse (world space) } } else { - // Regular socket dragging: existing logic + // Regular socket dragging: positions in world space PointF center = new PointF(dragSocket.X + dragSocket.Width / 2f, dragSocket.Y + dragSocket.Height / 2f); + PointF mouseWorldPos = ScreenToWorld(PointToClient(em)); + if (dragSocket.Input) { - dragConnectionBegin.X += em.X - lastmpos.X; - dragConnectionBegin.Y += em.Y - lastmpos.Y; + dragConnectionBegin = mouseWorldPos; dragConnectionEnd = center; - OnShowLocation(new RectangleF(dragConnectionBegin, new SizeF(10, 10))); } else { dragConnectionBegin = center; - dragConnectionEnd.X += em.X - lastmpos.X; - dragConnectionEnd.Y += em.Y - lastmpos.Y; - OnShowLocation(new RectangleF(dragConnectionEnd, new SizeF(10, 10))); + dragConnectionEnd = mouseWorldPos; } } } @@ -281,7 +424,10 @@ private void NodesControl_MouseMove(object sender, MouseEventArgs e) /// True if connection dragging was initiated private bool TryStartConnectionDrag(Point location) { - NodeConnection hitConnection = graph.GetConnectionAtPoint(new PointF(location.X, location.Y)); + // Convert screen coordinates to world coordinates + PointF worldLocation = ScreenToWorld(location); + + NodeConnection hitConnection = graph.GetConnectionAtPoint(worldLocation); if (hitConnection == null || mdown) return false; // Start dragging the connection @@ -314,16 +460,16 @@ private bool TryStartConnectionDrag(Point location) // Closer to output - disconnect from output, keep input fixed, drag towards new output dragSocket = inputSocket; dragSocketNode = hitConnection.InputNode; - dragConnectionBegin = new PointF(location.X, location.Y); // Floating end starts at mouse + dragConnectionBegin = worldLocation; // Floating end starts at mouse (world space) dragConnectionEnd = inputCenter; // Fixed end stays at input socket } else { - // Closer to input - disconnect from input, keep output fixed, drag towards new input + // Closer to input - disconnect from input, keep output fixed, drag towards new input dragSocket = outputSocket; dragSocketNode = hitConnection.OutputNode; dragConnectionBegin = outputCenter; // Fixed end stays at output socket - dragConnectionEnd = new PointF(location.X, location.Y); // Floating end starts at mouse + dragConnectionEnd = worldLocation; // Floating end starts at mouse (world space) } } @@ -343,12 +489,21 @@ private bool TryStartConnectionDrag(Point location) /// The selected node if header was clicked, null otherwise private NodeVisual TrySelectNodeHeader(Point location) { + // Convert screen coordinates to world coordinates + PointF worldLocation = ScreenToWorld(location); + NodeVisual node = graph.Nodes.OrderBy(x => x.Order).FirstOrDefault( - x => new RectangleF(new PointF(x.X, x.Y), x.GetHeaderSize()).Contains(location)); + x => new RectangleF(new PointF(x.X, x.Y), x.GetHeaderSize()).Contains(worldLocation)); if (node != null && !mdown) { - node.IsSelected = true; + // If the node wasn't already selected, select it + // If it was already selected, keep it selected (for multi-drag) + if (!node.IsSelected) + { + node.IsSelected = true; + } + node.Order = graph.Nodes.Min(x => x.Order) - 1; if (node.CustomEditor != null) { @@ -372,13 +527,16 @@ private NodeVisual TryHandleSocketInteraction(Point location, NodeVisual targetN { if (targetNode != null || mdown) return targetNode; + // Convert screen coordinates to world coordinates + PointF worldLocation = ScreenToWorld(location); + NodeVisual nodeWhole = graph.Nodes.OrderBy(x => x.Order).FirstOrDefault( - x => new RectangleF(new PointF(x.X, x.Y), x.GetNodeBounds()).Contains(location)); + x => new RectangleF(new PointF(x.X, x.Y), x.GetNodeBounds()).Contains(worldLocation)); if (nodeWhole == null) return null; targetNode = nodeWhole; - SocketVisual socket = nodeWhole.GetSockets().FirstOrDefault(x => x.GetBounds().Contains(location)); + SocketVisual socket = nodeWhole.GetSockets().FirstOrDefault(x => x.GetBounds().Contains(worldLocation)); if (socket == null) return targetNode; @@ -423,8 +581,10 @@ private NodeVisual TryHandleSocketInteraction(Point location, NodeVisual targetN dragSocketNode = nodeWhole; } - dragConnectionBegin = new PointF(location.X, location.Y); - dragConnectionEnd = new PointF(location.X, location.Y); + // Convert to world coordinates for connection dragging + PointF worldLoc = ScreenToWorld(location); + dragConnectionBegin = worldLoc; + dragConnectionEnd = worldLoc; mdown = true; lastmpos = PointToScreen(location); @@ -438,7 +598,15 @@ private void NodesControl_MouseDown(object sender, MouseEventArgs e) selectionStart = PointF.Empty; Focus(); - if ((ModifierKeys & Keys.Shift) != Keys.Shift) + // Check if clicking on an already selected node first + PointF worldLocation = ScreenToWorld(e.Location); + NodeVisual clickedNode = graph.Nodes.OrderBy(x => x.Order).FirstOrDefault( + x => new RectangleF(new PointF(x.X, x.Y), x.GetNodeBounds()).Contains(worldLocation)); + + bool clickedOnSelectedNode = clickedNode != null && clickedNode.IsSelected; + + // Only clear selection if not shift-clicking and not clicking on already selected node + if ((ModifierKeys & Keys.Shift) != Keys.Shift && !clickedOnSelectedNode) { graph.Nodes.ForEach(x => x.IsSelected = false); } @@ -468,6 +636,14 @@ private void NodesControl_MouseDown(object sender, MouseEventArgs e) OnNodeContextSelected(selectedNode.GetNodeContext()); } } + else if (e.Button == MouseButtons.Right) + { + // Start panning mode + isPanning = true; + panStartPoint = e.Location; + rightMouseMoved = false; + Cursor = Cursors.SizeAll; + } needRepaint = true; } @@ -511,26 +687,40 @@ private Assembly AssemblyResolver(AssemblyName assemblyName) private void NodesControl_MouseUp(object sender, MouseEventArgs e) { + // Handle right-click panning end + if (e.Button == MouseButtons.Right) + { + isPanning = false; + Cursor = Cursors.Default; + } + if (selectionStart != PointF.Empty) { - RectangleF rect = MakeRect(selectionStart, selectionEnd); + // Convert selection rectangle to world coordinates + PointF worldStart = ScreenToWorld(Point.Round(selectionStart)); + PointF worldEnd = ScreenToWorld(Point.Round(selectionEnd)); + RectangleF rect = MakeRect(worldStart, worldEnd); + graph.Nodes.ForEach( - x => x.IsSelected = rect.Contains(new RectangleF(new PointF(x.X, x.Y), x.GetNodeBounds()))); + x => x.IsSelected = rect.IntersectsWith(new RectangleF(new PointF(x.X, x.Y), x.GetNodeBounds()))); selectionStart = PointF.Empty; } // Handle connection dragging if (isDraggingConnection && dragSocket != null && dragConnection != null) { + // Convert mouse location to world coordinates + PointF worldLocation = ScreenToWorld(e.Location); + NodeVisual nodeWhole = graph.Nodes.OrderBy(x => x.Order).FirstOrDefault( - x => new RectangleF(new PointF(x.X, x.Y), x.GetNodeBounds()).Contains(e.Location)); + x => new RectangleF(new PointF(x.X, x.Y), x.GetNodeBounds()).Contains(worldLocation)); bool connectionRecreated = false; if (nodeWhole != null) { - SocketVisual socket = nodeWhole.GetSockets().FirstOrDefault(x => x.GetBounds().Contains(e.Location)); + SocketVisual socket = nodeWhole.GetSockets().FirstOrDefault(x => x.GetBounds().Contains(worldLocation)); if (socket != null && IsConnectable(dragSocket, socket) && dragSocket.Input != socket.Input) { // Recreate the connection with new endpoint @@ -576,12 +766,15 @@ private void NodesControl_MouseUp(object sender, MouseEventArgs e) } else if (dragSocket != null) { + // Convert mouse location to world coordinates + PointF worldLocation = ScreenToWorld(e.Location); + NodeVisual nodeWhole = graph.Nodes.OrderBy(x => x.Order).FirstOrDefault( - x => new RectangleF(new PointF(x.X, x.Y), x.GetNodeBounds()).Contains(e.Location)); + x => new RectangleF(new PointF(x.X, x.Y), x.GetNodeBounds()).Contains(worldLocation)); if (nodeWhole != null) { - SocketVisual socket = nodeWhole.GetSockets().FirstOrDefault(x => x.GetBounds().Contains(e.Location)); + SocketVisual socket = nodeWhole.GetSockets().FirstOrDefault(x => x.GetBounds().Contains(worldLocation)); if (socket != null) { if (IsConnectable(dragSocket, socket) && dragSocket.Input != socket.Input) @@ -924,7 +1117,8 @@ private void NodesControl_MouseClick(object sender, MouseEventArgs e) if (Context == null) return; - if (e.Button == MouseButtons.Right) + // Only show context menu if we didn't drag + if (e.Button == MouseButtons.Right && !rightMouseMoved) { var methods = Context.GetType().GetMethods(); var nodes = diff --git a/NodeEditor/SocketVisual.cs b/NodeEditor/SocketVisual.cs index 25ea70c..af823c3 100644 --- a/NodeEditor/SocketVisual.cs +++ b/NodeEditor/SocketVisual.cs @@ -33,12 +33,31 @@ internal class SocketVisual public float Y { get; set; } public float Width { get; set; } public float Height { get; set; } - public string Name { get; set; } + + private string _name; + private string _displayName; + + public string Name + { + get => _name; + set + { + _name = value; + // Cache the display name when the name is set + _displayName = ToTitleCase(value); + } + } + + /// + /// Gets the cached display name in Title Case format + /// + public string DisplayName => _displayName ?? (_displayName = ToTitleCase(_name)); + public Type Type { get; set; } public bool Input { get; set; } public object Value { get; set; } public bool IsMainExecution { get; set; } - + /// /// Runtime type for dynamic sockets (may differ from static Type) /// @@ -49,6 +68,34 @@ public bool IsExecution get { return Type.Name.Replace("&", "") == typeof (ExecutionPath).Name; } } + /// + /// Converts a camelCase or PascalCase string to Title Case with spaces + /// + private string ToTitleCase(string name) + { + if (string.IsNullOrEmpty(name)) + return name; + + // Insert spaces before uppercase letters (except the first one) + var result = new StringBuilder(); + for (int i = 0; i < name.Length; i++) + { + if (i > 0 && char.IsUpper(name[i]) && !char.IsUpper(name[i - 1])) + { + result.Append(' '); + } + result.Append(name[i]); + } + + // Ensure first letter is uppercase + if (result.Length > 0 && char.IsLower(result[0])) + { + result[0] = char.ToUpper(result[0]); + } + + return result.ToString(); + } + public void Draw(Graphics g, Point mouseLocation, MouseButtons mouseButtons) { RectangleF socketRect = new RectangleF(X, Y, Width, Height); @@ -63,20 +110,21 @@ public void Draw(Graphics g, Point mouseLocation, MouseButtons mouseButtons) g.SmoothingMode = SmoothingMode.HighSpeed; g.InterpolationMode = InterpolationMode.Low; - + + // Use cached display name if (Input) { StringFormat sf = new StringFormat(); sf.Alignment = StringAlignment.Near; - sf.LineAlignment = StringAlignment.Center; - g.DrawString(Name,SystemFonts.SmallCaptionFont, fontBrush, new RectangleF(X+Width+2,Y,1000,Height), sf); + sf.LineAlignment = StringAlignment.Center; + g.DrawString(DisplayName, SystemFonts.SmallCaptionFont, fontBrush, new RectangleF(X+Width+2,Y,1000,Height), sf); } else { StringFormat sf = new StringFormat(); sf.Alignment = StringAlignment.Far; sf.LineAlignment = StringAlignment.Center; - g.DrawString(Name, SystemFonts.SmallCaptionFont, fontBrush, new RectangleF(X-1000, Y, 1000, Height), sf); + g.DrawString(DisplayName, SystemFonts.SmallCaptionFont, fontBrush, new RectangleF(X-1000, Y, 1000, Height), sf); } g.InterpolationMode = InterpolationMode.HighQualityBilinear; diff --git a/SampleCommon/ControlNodeEditor.Designer.cs b/SampleCommon/ControlNodeEditor.Designer.cs index 302a97b..d292491 100644 --- a/SampleCommon/ControlNodeEditor.Designer.cs +++ b/SampleCommon/ControlNodeEditor.Designer.cs @@ -30,10 +30,10 @@ private void InitializeComponent() { this.splitContainer1 = new System.Windows.Forms.SplitContainer(); this.panel = new System.Windows.Forms.Panel(); + this.nodesControl = new NodeEditor.NodesControl(); this.splitContainer2 = new System.Windows.Forms.SplitContainer(); this.propertyGrid = new System.Windows.Forms.PropertyGrid(); this.buttonProcess = new System.Windows.Forms.Button(); - this.nodesControl = new NodeEditor.NodesControl(); ((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).BeginInit(); this.splitContainer1.Panel1.SuspendLayout(); this.splitContainer1.Panel2.SuspendLayout(); @@ -53,6 +53,7 @@ private void InitializeComponent() // // splitContainer1.Panel1 // + this.splitContainer1.Panel1.AutoScroll = true; this.splitContainer1.Panel1.Controls.Add(this.panel); // // splitContainer1.Panel2 @@ -72,6 +73,15 @@ private void InitializeComponent() this.panel.Size = new System.Drawing.Size(465, 485); this.panel.TabIndex = 0; // + // nodesControl + // + this.nodesControl.Context = null; + this.nodesControl.Dock = System.Windows.Forms.DockStyle.Fill; + this.nodesControl.Location = new System.Drawing.Point(0, 0); + this.nodesControl.Name = "nodesControl"; + this.nodesControl.Size = new System.Drawing.Size(465, 485); + this.nodesControl.TabIndex = 0; + // // splitContainer2 // this.splitContainer2.Dock = System.Windows.Forms.DockStyle.Fill; @@ -110,16 +120,6 @@ private void InitializeComponent() this.buttonProcess.UseVisualStyleBackColor = true; this.buttonProcess.Click += new System.EventHandler(this.buttonProcess_Click); // - // nodesControl - // - this.nodesControl.BackgroundImage = global::SampleCommon.Properties.Resources.grid; - this.nodesControl.Context = null; - this.nodesControl.Dock = System.Windows.Forms.DockStyle.Fill; - this.nodesControl.Location = new System.Drawing.Point(0, 0); - this.nodesControl.Name = "nodesControl"; - this.nodesControl.Size = new System.Drawing.Size(465, 485); - this.nodesControl.TabIndex = 0; - // // ControlNodeEditor // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); From 21f97876c8fef8a403d7c7a8da80c9f626f4fc8b Mon Sep 17 00:00:00 2001 From: Brian Whitenack Date: Sun, 14 Sep 2025 21:24:30 -0500 Subject: [PATCH 6/8] fix: Performance --- NodeEditor/NodesControl.cs | 144 ++++++++++++++++++++++--------------- 1 file changed, 85 insertions(+), 59 deletions(-) diff --git a/NodeEditor/NodesControl.cs b/NodeEditor/NodesControl.cs index b37ce66..eb7655b 100644 --- a/NodeEditor/NodesControl.cs +++ b/NodeEditor/NodesControl.cs @@ -129,7 +129,7 @@ public INodesContext Context public NodesControl() { InitializeComponent(); - timer.Interval = 30; + timer.Interval = 16; // ~60 FPS instead of 33 FPS timer.Tick += TimerOnTick; timer.Start(); KeyDown += OnKeyDown; @@ -223,6 +223,11 @@ private void DrawGrid(Graphics g) // Grid settings int baseGridSize = 20; float gridSize = baseGridSize * zoomLevel; // Scale grid with zoom + + // Skip grid drawing if too small or too large to be useful + if (gridSize < 2 || gridSize > 200) + return; + Color gridColor = Color.FromArgb(60, 0, 0, 0); // Light gray grid Color majorGridColor = Color.FromArgb(100, 0, 0, 0); // Darker for major lines @@ -255,6 +260,7 @@ private void DrawGrid(Graphics g) private void NodesControl_Paint(object sender, PaintEventArgs e) { + // Set graphics quality once e.Graphics.SmoothingMode = SmoothingMode.HighQuality; e.Graphics.InterpolationMode = InterpolationMode.HighQualityBilinear; @@ -262,7 +268,7 @@ private void NodesControl_Paint(object sender, PaintEventArgs e) DrawGrid(e.Graphics); // Save the original transform - var originalTransform = e.Graphics.Transform; + Matrix originalTransform = e.Graphics.Transform; // Apply zoom and pan transformation e.Graphics.TranslateTransform(panOffset.X, panOffset.Y); @@ -275,9 +281,11 @@ private void NodesControl_Paint(object sender, PaintEventArgs e) if (dragSocket != null) { - Pen pen = new Pen(Color.Black, 2 / zoomLevel); // Adjust pen width for zoom - // dragConnectionBegin and dragConnectionEnd are already in world space - NodesGraph.DrawConnection(e.Graphics, pen, dragConnectionBegin, dragConnectionEnd); + using (Pen pen = new Pen(Color.Black, 2 / zoomLevel)) // Adjust pen width for zoom + { + // dragConnectionBegin and dragConnectionEnd are already in world space + NodesGraph.DrawConnection(e.Graphics, pen, dragConnectionBegin, dragConnectionEnd); + } } // Restore transform for UI elements @@ -286,8 +294,12 @@ private void NodesControl_Paint(object sender, PaintEventArgs e) if (selectionStart != PointF.Empty) { Rectangle rect = Rectangle.Round(MakeRect(selectionStart, selectionEnd)); - e.Graphics.FillRectangle(new SolidBrush(Color.FromArgb(50, Color.CornflowerBlue)), rect); - e.Graphics.DrawRectangle(new Pen(Color.DodgerBlue), rect); + using (var fillBrush = new SolidBrush(Color.FromArgb(50, Color.CornflowerBlue))) + using (var borderPen = new Pen(Color.DodgerBlue)) + { + e.Graphics.FillRectangle(fillBrush, rect); + e.Graphics.DrawRectangle(borderPen, rect); + } } needRepaint = false; @@ -335,7 +347,7 @@ private void NodesControl_MouseMove(object sender, MouseEventArgs e) float dy = (em.Y - lastmpos.Y) / zoomLevel; // Cache selected nodes to avoid multiple LINQ queries - var selectedNodes = graph.Nodes.Where(x => x.IsSelected).ToList(); + List selectedNodes = graph.Nodes.Where(x => x.IsSelected).ToList(); if (selectedNodes.Count > 0) { @@ -413,8 +425,22 @@ private void NodesControl_MouseMove(object sender, MouseEventArgs e) } } lastmpos = em; + + // For immediate responsiveness during dragging, invalidate directly + // instead of waiting for timer + if (mdown || isDraggingConnection || isPanning) + { + Invalidate(); + } + else + { + needRepaint = true; + } + } + else + { + needRepaint = true; } - needRepaint = true; } /// @@ -1075,8 +1101,8 @@ private void DisconnectIncompatibleConnections(NodeVisual node, List OnNodeHint(""); @@ -1100,7 +1126,7 @@ private void AddToMenu(ToolStripItemCollection items, NodeToken token, string pa item.Click += click; item.Click += (sender, args) => { - var i = allContextItems.Keys.FirstOrDefault(x => x.Name == item.Name); + ToolStripMenuItem i = allContextItems.Keys.FirstOrDefault(x => x.Name == item.Name); allContextItems[i]++; }; item.MouseEnter += (sender, args) => OnNodeHint(token.Attribute.Description ?? ""); @@ -1120,8 +1146,8 @@ private void NodesControl_MouseClick(object sender, MouseEventArgs e) // Only show context menu if we didn't drag if (e.Button == MouseButtons.Right && !rightMouseMoved) { - var methods = Context.GetType().GetMethods(); - var nodes = + MethodInfo[] methods = Context.GetType().GetMethods(); + IEnumerable nodes = methods.Select( x => new @@ -1134,7 +1160,7 @@ private void NodesControl_MouseClick(object sender, MouseEventArgs e) .FirstOrDefault() }).Where(x => x.Attribute != null); - var context = new ContextMenuStrip(); + ContextMenuStrip context = new ContextMenuStrip(); if (graph.Nodes.Exists(x => x.IsSelected)) { context.Items.Add("Delete Node(s)", null, ((o, args) => @@ -1151,7 +1177,7 @@ private void NodesControl_MouseClick(object sender, MouseEventArgs e) })); if (graph.Nodes.Count(x => x.IsSelected) == 2) { - var sel = graph.Nodes.Where(x => x.IsSelected).ToArray(); + NodeVisual[] sel = graph.Nodes.Where(x => x.IsSelected).ToArray(); context.Items.Add("Check Impact", null, ((o, args) => { if (HasImpact(sel[0], sel[1]) || HasImpact(sel[1], sel[0])) @@ -1168,7 +1194,7 @@ private void NodesControl_MouseClick(object sender, MouseEventArgs e) } if (allContextItems.Values.Any(x => x > 0)) { - var handy = allContextItems.Where(x => x.Value > 0 && !string.IsNullOrEmpty(((x.Key.Tag) as NodeToken).Attribute.Menu)).OrderByDescending(x => x.Value).Take(8); + IEnumerable> handy = allContextItems.Where(x => x.Value > 0 && !string.IsNullOrEmpty(((x.Key.Tag) as NodeToken).Attribute.Menu)).OrderByDescending(x => x.Value).Take(8); foreach (var kv in handy) { context.Items.Add(kv.Key); @@ -1240,16 +1266,16 @@ private void ChangeSelectedNodesColor() private void DuplicateSelectedNodes() { - var cloned = new List(); + List cloned = new List(); foreach (var n in graph.Nodes.Where(x => x.IsSelected)) { int count = graph.Nodes.Count(x => x.IsSelected); - var ms = new MemoryStream(); - var bw = new BinaryWriter(ms); + MemoryStream ms = new MemoryStream(); + BinaryWriter bw = new BinaryWriter(ms); SerializeNode(bw, n); ms.Seek(0, SeekOrigin.Begin); - var br = new BinaryReader(ms); - var clone = DeserializeNode(br); + BinaryReader br = new BinaryReader(ms); + NodeVisual clone = DeserializeNode(br); clone.X += 40; clone.Y += 40; clone.GUID = Guid.NewGuid().ToString(); @@ -1459,7 +1485,7 @@ private void ExecuteFlowControlNode(NodeVisual flowControlNode, Queue(); + Queue nodeQueue = new Queue(); nodeQueue.Enqueue(node); while (nodeQueue.Count > 0) @@ -1472,7 +1498,7 @@ public void Execute(NodeVisual node = null) return; } - var init = nodeQueue.Dequeue() ?? graph.Nodes.FirstOrDefault(x => x.ExecInit); + NodeVisual init = nodeQueue.Dequeue() ?? graph.Nodes.FirstOrDefault(x => x.ExecInit); if (init != null) { init.Feedback = FeedbackType.Debug; @@ -1490,7 +1516,7 @@ public void Execute(NodeVisual node = null) // Normal node execution init.Execute(Context); - var connection = + NodeConnection connection = graph.Connections.FirstOrDefault( x => x.OutputNode == init && x.IsExecution && x.OutputSocket.Value != null && (x.OutputSocket.Value as ExecutionPath).IsSignaled); if (connection == null) @@ -1511,7 +1537,7 @@ public void Execute(NodeVisual node = null) { if (executionStack.Count > 0) { - var back = executionStack.Pop(); + NodeVisual back = executionStack.Pop(); back.IsBackExecuted = true; Execute(back); } @@ -1523,7 +1549,7 @@ public void Execute(NodeVisual node = null) public List GetNodes(params string[] nodeNames) { - var nodes = graph.Nodes.Where(x => nodeNames.Contains(x.Name)); + IEnumerable nodes = graph.Nodes.Where(x => nodeNames.Contains(x.Name)); return nodes.ToList(); } @@ -1538,8 +1564,8 @@ public bool AddNodeByMethodName(string methodName, float x, float y) { if (Context == null) return false; - var methods = Context.GetType().GetMethods(); - var nodeToken = methods.Select(m => new NodeToken() + MethodInfo[] methods = Context.GetType().GetMethods(); + NodeToken nodeToken = methods.Select(m => new NodeToken() { Method = m, Attribute = m.GetCustomAttributes(typeof(NodeAttribute), false) @@ -1549,7 +1575,7 @@ public bool AddNodeByMethodName(string methodName, float x, float y) if (nodeToken != null) { - var originalMouseLocation = lastMouseLocation; + Point originalMouseLocation = lastMouseLocation; lastMouseLocation = new Point((int)x, (int)y); AddNodeToGraph(nodeToken); lastMouseLocation = originalMouseLocation; @@ -1570,8 +1596,8 @@ public bool AddNodeByName(string nodeName, float x, float y) { if (Context == null) return false; - var methods = Context.GetType().GetMethods(); - var nodeToken = methods.Select(m => new NodeToken() + MethodInfo[] methods = Context.GetType().GetMethods(); + NodeToken nodeToken = methods.Select(m => new NodeToken() { Method = m, Attribute = m.GetCustomAttributes(typeof(NodeAttribute), false) @@ -1581,7 +1607,7 @@ public bool AddNodeByName(string nodeName, float x, float y) if (nodeToken != null) { - var originalMouseLocation = lastMouseLocation; + Point originalMouseLocation = lastMouseLocation; lastMouseLocation = new Point((int)x, (int)y); AddNodeToGraph(nodeToken); lastMouseLocation = originalMouseLocation; @@ -1593,7 +1619,7 @@ public bool AddNodeByName(string nodeName, float x, float y) public bool HasImpact(NodeVisual startNode, NodeVisual endNode) { - var connections = graph.Connections.Where(x => x.OutputNode == startNode && !x.IsExecution); + IEnumerable connections = graph.Connections.Where(x => x.OutputNode == startNode && !x.IsExecution); foreach (var connection in connections) { if (connection.InputNode == endNode) @@ -1612,7 +1638,7 @@ public bool HasImpact(NodeVisual startNode, NodeVisual endNode) public void ExecuteResolving(params string[] nodeNames) { - var nodes = graph.Nodes.Where(x => nodeNames.Contains(x.Name)); + IEnumerable nodes = graph.Nodes.Where(x => nodeNames.Contains(x.Name)); foreach (var node in nodes) { @@ -1631,7 +1657,7 @@ private void ExecuteResolvingInternal(NodeVisual node) continue; } - var connection = + NodeConnection connection = graph.Connections.FirstOrDefault(x => x.InputNode == node && x.InputSocketName == input.Name); if (connection != null) { @@ -1662,7 +1688,7 @@ private void Resolve(NodeVisual node) continue; } - var connection = GetConnection(node.GUID + input.Name); + NodeConnection connection = GetConnection(node.GUID + input.Name); //graph.Connections.FirstOrDefault(x => x.InputNode == node && x.InputSocketName == input.Name); if (connection != null) { @@ -1698,30 +1724,30 @@ private NodeConnection GetConnection(string v) public string ExportToXml() { - var xml = new XmlDocument(); + XmlDocument xml = new XmlDocument(); XmlElement el = (XmlElement)xml.AppendChild(xml.CreateElement("NodeGrap")); el.SetAttribute("Created", DateTime.Now.ToString()); - var nodes = el.AppendChild(xml.CreateElement("Nodes")); + XmlNode nodes = el.AppendChild(xml.CreateElement("Nodes")); foreach (var node in graph.Nodes) { - var xmlNode = (XmlElement)nodes.AppendChild(xml.CreateElement("Node")); + XmlElement xmlNode = (XmlElement)nodes.AppendChild(xml.CreateElement("Node")); xmlNode.SetAttribute("Name", node.XmlExportName); xmlNode.SetAttribute("Id", node.GetGuid()); - var xmlContext = (XmlElement)xmlNode.AppendChild(xml.CreateElement("Context")); + XmlElement xmlContext = (XmlElement)xmlNode.AppendChild(xml.CreateElement("Context")); DynamicNodeContext context = node.GetNodeContext(); foreach (var kv in context) { - var ce = (XmlElement)xmlContext.AppendChild(xml.CreateElement("ContextMember")); + XmlElement ce = (XmlElement)xmlContext.AppendChild(xml.CreateElement("ContextMember")); ce.SetAttribute("Name", kv); ce.SetAttribute("Value", Convert.ToString(context[kv] ?? "")); ce.SetAttribute("Type", context[kv] == null ? "" : context[kv].GetType().FullName); } } - var connections = el.AppendChild(xml.CreateElement("Connections")); + XmlNode connections = el.AppendChild(xml.CreateElement("Connections")); foreach (var conn in graph.Connections) { - var xmlConn = (XmlElement)nodes.AppendChild(xml.CreateElement("Connection")); + XmlElement xmlConn = (XmlElement)nodes.AppendChild(xml.CreateElement("Connection")); xmlConn.SetAttribute("OutputNodeId", conn.OutputNode.GetGuid()); xmlConn.SetAttribute("OutputNodeSocket", conn.OutputSocketName); xmlConn.SetAttribute("InputNodeId", conn.InputNode.GetGuid()); @@ -1807,29 +1833,29 @@ public void Deserialize(byte[] data) { using (var br = new BinaryReader(new MemoryStream(data))) { - var ident = br.ReadString(); + string ident = br.ReadString(); if (ident != "NodeSystemP") return; rebuildConnectionDictionary = true; graph.Connections.Clear(); graph.Nodes.Clear(); Controls.Clear(); - var version = br.ReadInt32(); + int version = br.ReadInt32(); int nodeCount = br.ReadInt32(); for (int i = 0; i < nodeCount; i++) { - var nv = DeserializeNode(br); + NodeVisual nv = DeserializeNode(br); graph.Nodes.Add(nv); } - var connectionsCount = br.ReadInt32(); + int connectionsCount = br.ReadInt32(); for (int i = 0; i < connectionsCount; i++) { - var con = new NodeConnection(); - var og = br.ReadString(); + NodeConnection con = new NodeConnection(); + string og = br.ReadString(); con.OutputNode = graph.Nodes.FirstOrDefault(x => x.GUID == og); con.OutputSocketName = br.ReadString(); - var ig = br.ReadString(); + string ig = br.ReadString(); con.InputNode = graph.Nodes.FirstOrDefault(x => x.GUID == ig); con.InputSocketName = br.ReadString(); br.ReadBytes(br.ReadInt32()); //read additional data @@ -1856,10 +1882,10 @@ private NodeVisual DeserializeNode(BinaryReader br) nv.ExecInit = br.ReadBoolean(); nv.Name = br.ReadString(); nv.Order = br.ReadInt32(); - var customEditorAssembly = br.ReadString(); - var customEditor = br.ReadString(); + string customEditorAssembly = br.ReadString(); + string customEditor = br.ReadString(); nv.Type = Context.GetType().GetMethod(br.ReadString()); - var attribute = nv.Type.GetCustomAttributes(typeof(NodeAttribute), false) + NodeAttribute attribute = (NodeAttribute)nv.Type.GetCustomAttributes(typeof(NodeAttribute), false) .Cast() .FirstOrDefault(); if (attribute != null) @@ -1868,7 +1894,7 @@ private NodeVisual DeserializeNode(BinaryReader br) nv.CustomHeight = attribute.Height; } nv.GetNodeContext().Deserialize(br.ReadBytes(br.ReadInt32())); - var additional = br.ReadInt32(); //read additional data + int additional = br.ReadInt32(); //read additional data if (additional >= 4) { nv.Int32Tag = br.ReadInt32(); @@ -1961,7 +1987,7 @@ public string SerializeToJson() // Serialize connections foreach (var connection in graph.Connections) { - var connModel = new ConnectionModel + ConnectionModel connModel = new ConnectionModel { OutputNodeId = connection.OutputNode.GUID, OutputSocketName = connection.OutputSocketName, @@ -1979,7 +2005,7 @@ public string SerializeToJson() /// public void DeserializeFromJson(string json) { - var model = JsonConvert.DeserializeObject(json); + NodeGraphModel model = JsonConvert.DeserializeObject(json); if (model == null) return; @@ -2068,7 +2094,7 @@ public void DeserializeFromJson(string json) // Deserialize connections foreach (var connModel in model.Connections) { - var connection = new NodeConnection(); + NodeConnection connection = new NodeConnection(); connection.OutputNode = graph.Nodes.FirstOrDefault(x => x.GUID == connModel.OutputNodeId); connection.OutputSocketName = connModel.OutputSocketName; connection.InputNode = graph.Nodes.FirstOrDefault(x => x.GUID == connModel.InputNodeId); From 448d3b51fe3ab98ec8b8a3a23b0eb8ef03500958 Mon Sep 17 00:00:00 2001 From: Brian Whitenack Date: Sun, 14 Sep 2025 21:38:17 -0500 Subject: [PATCH 7/8] feat: Toolbox --- .claude/settings.local.json | 4 +- MathSample/FormMathSample.Designer.cs | 157 ++++++- MathSample/FormMathSample.cs | 20 +- MathSample/PartCalculation.cs | 39 +- NodeEditor/NodeEditor.csproj | 3 + NodeEditor/NodeToolboxPanel.cs | 462 +++++++++++++++++++ NodeEditor/NodesControl.cs | 162 +++++++ SampleCommon/NodeToolboxPanel.Designer.cs | 44 ++ SampleCommon/NodeToolboxPanel.cs | 525 ++++++++++++++++++++++ 9 files changed, 1393 insertions(+), 23 deletions(-) create mode 100644 NodeEditor/NodeToolboxPanel.cs create mode 100644 SampleCommon/NodeToolboxPanel.Designer.cs create mode 100644 SampleCommon/NodeToolboxPanel.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b082b02..0d0afbe 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,9 @@ "Bash(where msbuild)", "Bash(\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional\\MSBuild\\Current\\Bin\\amd64\\MSBuild.exe\" NodeEditorNodeEditor.csproj)", "Bash(\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional\\MSBuild\\Current\\Bin\\amd64\\MSBuild.exe\" \"NodeEditor\\NodeEditor.csproj\")", - "Bash(\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional\\MSBuild\\Current\\Bin\\amd64\\MSBuild.exe\" NodeEditorWinforms.sln)" + "Bash(\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional\\MSBuild\\Current\\Bin\\amd64\\MSBuild.exe\" NodeEditorWinforms.sln)", + "Bash(del:*)", + "Bash(cmd /c:*)" ], "deny": [], "ask": [] diff --git a/MathSample/FormMathSample.Designer.cs b/MathSample/FormMathSample.Designer.cs index 665bc08..c6497a1 100644 --- a/MathSample/FormMathSample.Designer.cs +++ b/MathSample/FormMathSample.Designer.cs @@ -32,15 +32,23 @@ private void InitializeComponent() this.toolStrip1 = new System.Windows.Forms.ToolStrip(); this.btnSave = new System.Windows.Forms.ToolStripButton(); this.btnLoad = new System.Windows.Forms.ToolStripButton(); + this.btnNew = new System.Windows.Forms.ToolStripButton(); this.controlNodeEditor = new SampleCommon.ControlNodeEditor(); this.splitContainer1 = new System.Windows.Forms.SplitContainer(); this.tabControl1 = new System.Windows.Forms.TabControl(); this.tabMeasurements = new System.Windows.Forms.TabPage(); + this.btnUpdateMeasurements = new System.Windows.Forms.Button(); this.txtMeasurements = new System.Windows.Forms.TextBox(); this.tabParts = new System.Windows.Forms.TabPage(); this.txtParts = new System.Windows.Forms.TextBox(); - this.btnNew = new System.Windows.Forms.ToolStripButton(); - this.btnUpdateMeasurements = new System.Windows.Forms.Button(); + this.tabVariables = new System.Windows.Forms.TabPage(); + this.btnUpdateVariables = new System.Windows.Forms.Button(); + this.txtVariables = new System.Windows.Forms.TextBox(); + this.tabFeatureFlags = new System.Windows.Forms.TabPage(); + this.btnUpdateFeatureFlags = new System.Windows.Forms.Button(); + this.txtFeatureFlags = new System.Windows.Forms.TextBox(); + this.tabNodes = new System.Windows.Forms.TabPage(); + this.ntbNodes = new NodeEditor.NodeToolboxPanel(); this.toolStrip1.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)(this.splitContainer1)).BeginInit(); this.splitContainer1.Panel1.SuspendLayout(); @@ -49,6 +57,9 @@ private void InitializeComponent() this.tabControl1.SuspendLayout(); this.tabMeasurements.SuspendLayout(); this.tabParts.SuspendLayout(); + this.tabVariables.SuspendLayout(); + this.tabFeatureFlags.SuspendLayout(); + this.tabNodes.SuspendLayout(); this.SuspendLayout(); // // toolStrip1 @@ -83,6 +94,16 @@ private void InitializeComponent() this.btnLoad.Text = "Load"; this.btnLoad.Click += new System.EventHandler(this.btnLoad_Click); // + // btnNew + // + this.btnNew.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnNew.Image = ((System.Drawing.Image)(resources.GetObject("btnNew.Image"))); + this.btnNew.ImageTransparentColor = System.Drawing.Color.Magenta; + this.btnNew.Name = "btnNew"; + this.btnNew.Size = new System.Drawing.Size(35, 22); + this.btnNew.Text = "New"; + this.btnNew.Click += new System.EventHandler(this.btnNew_Click); + // // controlNodeEditor // this.controlNodeEditor.Dock = System.Windows.Forms.DockStyle.Fill; @@ -110,8 +131,11 @@ private void InitializeComponent() // // tabControl1 // + this.tabControl1.Controls.Add(this.tabNodes); this.tabControl1.Controls.Add(this.tabMeasurements); this.tabControl1.Controls.Add(this.tabParts); + this.tabControl1.Controls.Add(this.tabVariables); + this.tabControl1.Controls.Add(this.tabFeatureFlags); this.tabControl1.Dock = System.Windows.Forms.DockStyle.Fill; this.tabControl1.Location = new System.Drawing.Point(0, 0); this.tabControl1.Name = "tabControl1"; @@ -131,6 +155,18 @@ private void InitializeComponent() this.tabMeasurements.Text = "Measurements"; this.tabMeasurements.UseVisualStyleBackColor = true; // + // btnUpdateMeasurements + // + this.btnUpdateMeasurements.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.btnUpdateMeasurements.Location = new System.Drawing.Point(3, 6); + this.btnUpdateMeasurements.Name = "btnUpdateMeasurements"; + this.btnUpdateMeasurements.Size = new System.Drawing.Size(134, 23); + this.btnUpdateMeasurements.TabIndex = 2; + this.btnUpdateMeasurements.Text = "Update"; + this.btnUpdateMeasurements.UseVisualStyleBackColor = true; + this.btnUpdateMeasurements.Click += new System.EventHandler(this.btnUpdateMeasurements_Click); + // // txtMeasurements // this.txtMeasurements.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) @@ -163,27 +199,97 @@ private void InitializeComponent() this.txtParts.Size = new System.Drawing.Size(134, 453); this.txtParts.TabIndex = 0; // - // btnNew + // tabVariables // - this.btnNew.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; - this.btnNew.Image = ((System.Drawing.Image)(resources.GetObject("btnNew.Image"))); - this.btnNew.ImageTransparentColor = System.Drawing.Color.Magenta; - this.btnNew.Name = "btnNew"; - this.btnNew.Size = new System.Drawing.Size(35, 22); - this.btnNew.Text = "New"; - this.btnNew.Click += new System.EventHandler(this.btnNew_Click); + this.tabVariables.Controls.Add(this.btnUpdateVariables); + this.tabVariables.Controls.Add(this.txtVariables); + this.tabVariables.Location = new System.Drawing.Point(4, 22); + this.tabVariables.Name = "tabVariables"; + this.tabVariables.Padding = new System.Windows.Forms.Padding(3); + this.tabVariables.Size = new System.Drawing.Size(140, 459); + this.tabVariables.TabIndex = 2; + this.tabVariables.Text = "Variables"; + this.tabVariables.UseVisualStyleBackColor = true; // - // btnUpdateMeasurements + // btnUpdateVariables // - this.btnUpdateMeasurements.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + this.btnUpdateVariables.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) | System.Windows.Forms.AnchorStyles.Right))); - this.btnUpdateMeasurements.Location = new System.Drawing.Point(3, 6); - this.btnUpdateMeasurements.Name = "btnUpdateMeasurements"; - this.btnUpdateMeasurements.Size = new System.Drawing.Size(134, 23); - this.btnUpdateMeasurements.TabIndex = 2; - this.btnUpdateMeasurements.Text = "Update"; - this.btnUpdateMeasurements.UseVisualStyleBackColor = true; - this.btnUpdateMeasurements.Click += new System.EventHandler(this.btnUpdateMeasurements_Click); + this.btnUpdateVariables.Location = new System.Drawing.Point(3, 4); + this.btnUpdateVariables.Name = "btnUpdateVariables"; + this.btnUpdateVariables.Size = new System.Drawing.Size(134, 23); + this.btnUpdateVariables.TabIndex = 4; + this.btnUpdateVariables.Text = "Update"; + this.btnUpdateVariables.UseVisualStyleBackColor = true; + this.btnUpdateVariables.Click += new System.EventHandler(this.btnUpdateVariables_Click); + // + // txtVariables + // + this.txtVariables.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.txtVariables.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.txtVariables.Location = new System.Drawing.Point(3, 30); + this.txtVariables.Multiline = true; + this.txtVariables.Name = "txtVariables"; + this.txtVariables.Size = new System.Drawing.Size(134, 424); + this.txtVariables.TabIndex = 3; + // + // tabFeatureFlags + // + this.tabFeatureFlags.Controls.Add(this.btnUpdateFeatureFlags); + this.tabFeatureFlags.Controls.Add(this.txtFeatureFlags); + this.tabFeatureFlags.Location = new System.Drawing.Point(4, 22); + this.tabFeatureFlags.Name = "tabFeatureFlags"; + this.tabFeatureFlags.Padding = new System.Windows.Forms.Padding(3); + this.tabFeatureFlags.Size = new System.Drawing.Size(140, 459); + this.tabFeatureFlags.TabIndex = 3; + this.tabFeatureFlags.Text = "Feature Flags"; + this.tabFeatureFlags.UseVisualStyleBackColor = true; + // + // btnUpdateFeatureFlags + // + this.btnUpdateFeatureFlags.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.btnUpdateFeatureFlags.Location = new System.Drawing.Point(3, 4); + this.btnUpdateFeatureFlags.Name = "btnUpdateFeatureFlags"; + this.btnUpdateFeatureFlags.Size = new System.Drawing.Size(134, 23); + this.btnUpdateFeatureFlags.TabIndex = 4; + this.btnUpdateFeatureFlags.Text = "Update"; + this.btnUpdateFeatureFlags.UseVisualStyleBackColor = true; + this.btnUpdateFeatureFlags.Click += new System.EventHandler(this.btnUpdateFeatureFlags_Click); + // + // txtFeatureFlags + // + this.txtFeatureFlags.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.txtFeatureFlags.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.txtFeatureFlags.Location = new System.Drawing.Point(3, 30); + this.txtFeatureFlags.Multiline = true; + this.txtFeatureFlags.Name = "txtFeatureFlags"; + this.txtFeatureFlags.Size = new System.Drawing.Size(134, 424); + this.txtFeatureFlags.TabIndex = 3; + // + // tabNodes + // + this.tabNodes.Controls.Add(this.ntbNodes); + this.tabNodes.Location = new System.Drawing.Point(4, 22); + this.tabNodes.Name = "tabNodes"; + this.tabNodes.Padding = new System.Windows.Forms.Padding(3); + this.tabNodes.Size = new System.Drawing.Size(140, 459); + this.tabNodes.TabIndex = 4; + this.tabNodes.Text = "Toolbox"; + this.tabNodes.UseVisualStyleBackColor = true; + // + // ntbNodes + // + this.ntbNodes.Context = null; + this.ntbNodes.Dock = System.Windows.Forms.DockStyle.Fill; + this.ntbNodes.Location = new System.Drawing.Point(3, 3); + this.ntbNodes.Name = "ntbNodes"; + this.ntbNodes.Size = new System.Drawing.Size(134, 453); + this.ntbNodes.TabIndex = 0; // // FormMathSample // @@ -206,6 +312,11 @@ private void InitializeComponent() this.tabMeasurements.PerformLayout(); this.tabParts.ResumeLayout(false); this.tabParts.PerformLayout(); + this.tabVariables.ResumeLayout(false); + this.tabVariables.PerformLayout(); + this.tabFeatureFlags.ResumeLayout(false); + this.tabFeatureFlags.PerformLayout(); + this.tabNodes.ResumeLayout(false); this.ResumeLayout(false); this.PerformLayout(); @@ -225,6 +336,14 @@ private void InitializeComponent() private System.Windows.Forms.TextBox txtParts; private System.Windows.Forms.ToolStripButton btnNew; private System.Windows.Forms.Button btnUpdateMeasurements; + private System.Windows.Forms.TabPage tabVariables; + private System.Windows.Forms.Button btnUpdateVariables; + private System.Windows.Forms.TextBox txtVariables; + private System.Windows.Forms.TabPage tabFeatureFlags; + private System.Windows.Forms.Button btnUpdateFeatureFlags; + private System.Windows.Forms.TextBox txtFeatureFlags; + private System.Windows.Forms.TabPage tabNodes; + private NodeEditor.NodeToolboxPanel ntbNodes; } } diff --git a/MathSample/FormMathSample.cs b/MathSample/FormMathSample.cs index 7059f00..e167c3f 100644 --- a/MathSample/FormMathSample.cs +++ b/MathSample/FormMathSample.cs @@ -90,7 +90,15 @@ private void FormMathSample_Load(object sender, EventArgs e) //Context assignment controlNodeEditor.nodesControl.Context = context; controlNodeEditor.nodesControl.OnNodeContextSelected += NodesControlOnOnNodeContextSelected; - + + // Initialize toolbox with context + ntbNodes.Context = context; + ntbNodes.RefreshNodes(); + + // Wire up drag preview events + ntbNodes.OnDragPreviewStart += (item) => controlNodeEditor.nodesControl.StartDragPreview(item); + ntbNodes.OnDragPreviewEnd += () => controlNodeEditor.nodesControl.StopDragPreview(); + // Add default nodes AddDefaultNodes(); } @@ -190,5 +198,15 @@ private void btnUpdateMeasurements_Click(object sender, EventArgs e) { context.Measurements = JsonConvert.DeserializeObject>(txtMeasurements.Text); } + + private void btnUpdateFeatureFlags_Click(object sender, EventArgs e) + { + context.FeatureFlags = JsonConvert.DeserializeObject>(txtFeatureFlags.Text); + } + + private void btnUpdateVariables_Click(object sender, EventArgs e) + { + context.ProjectGuideVariables = JsonConvert.DeserializeObject>(txtVariables.Text); + } } } diff --git a/MathSample/PartCalculation.cs b/MathSample/PartCalculation.cs index 5280b57..fa810c6 100644 --- a/MathSample/PartCalculation.cs +++ b/MathSample/PartCalculation.cs @@ -24,12 +24,16 @@ public enum UnitOfMeasure public event Action FeedbackInfo; public List Measurements { get; set; } + public Dictionary ProjectGuideVariables { get; set; } + public Dictionary FeatureFlags { get; set; } public List Parts { get; set; } public PartCalculation() { Measurements = new List(); Parts = new List(); + ProjectGuideVariables = new Dictionary(); + FeatureFlags = new Dictionary(); } public void FinishExecution() @@ -43,12 +47,43 @@ public void FilterToType(List measurements, string type, out List m.Type.Equals(type, StringComparison.OrdinalIgnoreCase)).ToList(); } - [Node("Measurement List", "Measurements", "Basic", "Get the current measurement list", false)] + [Node("Measurement List", "Part Calculation", "Basic", "Get the current measurement list", false)] public void MeasurementList(out List measurements) { measurements = Measurements; } + [Node("Feature Flag", "Part Calculation", "Basic", "Get a feature flag value", false)] + public void FeatureFlag(string featureFlagName, out bool featureFlagValue) + { + if (!FeatureFlags.TryGetValue(featureFlagName, out featureFlagValue)) + { + featureFlagValue = false; + } + } + + [Node("Project Guide Variable String", "Part Calculation", "Basic", "Get a project guide variable string", false)] + public void ProjectGuideVariableString(string variableName, out string variableValue) + { + if (ProjectGuideVariables.TryGetValue(variableName, out object variableValueObject)) + { + variableValue = variableValueObject.ToString(); + } + else + { + variableValue = string.Empty; + } + } + + [Node("Project Guide Variable Number", "Part Calculation", "Basic", "Get a project guide variable number", false)] + public void ProjectGuideVariableNumber(string variableName, out double variableValue) + { + if (!ProjectGuideVariables.TryGetValue(variableName, out object variableValueObject) || !double.TryParse(variableValueObject.ToString(), out variableValue)) + { + variableValue = 0; + } + } + [Node("Number Selection", "Measurements", "Basic", "Select a number from the measurement", false)] public void NumberSelection(Measurement measurement, string selectionName, out double selectionValue) { @@ -273,7 +308,7 @@ public void ToListNode( list = new List() { item }; } - [Node("Starter", "Helper", "Basic", "Starts execution", true, true)] + [Node("Starter", "Flow Control", "Basic", "Starts execution", true, true)] public void Starter() { diff --git a/NodeEditor/NodeEditor.csproj b/NodeEditor/NodeEditor.csproj index 1ab9de3..3840331 100644 --- a/NodeEditor/NodeEditor.csproj +++ b/NodeEditor/NodeEditor.csproj @@ -66,6 +66,9 @@ NodesControl.cs + + UserControl + diff --git a/NodeEditor/NodeToolboxPanel.cs b/NodeEditor/NodeToolboxPanel.cs new file mode 100644 index 0000000..dcf0580 --- /dev/null +++ b/NodeEditor/NodeToolboxPanel.cs @@ -0,0 +1,462 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Linq; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Windows.Forms; + +namespace NodeEditor +{ + /// + /// A toolbox panel that displays available node types with collapsible categories and drag-and-drop support + /// + public class NodeToolboxPanel : UserControl + { + private Dictionary categoryPanels = new Dictionary(); + private Panel mainPanel; + private Timer resizeTimer; + + public INodesContext Context { get; set; } + + // Events for drag preview communication + public event Action OnDragPreviewStart; + public event Action OnDragPreviewEnd; + + public NodeToolboxPanel() + { + SetupMainPanel(); + SetupResizeTimer(); + } + + private void SetupMainPanel() + { + mainPanel = new Panel + { + Dock = DockStyle.Fill, + AutoScroll = true, + BackColor = Color.FromArgb(240, 240, 240) + }; + + Controls.Add(mainPanel); + } + + private void SetupResizeTimer() + { + resizeTimer = new Timer(); + resizeTimer.Interval = 100; + resizeTimer.Tick += (s, e) => + { + resizeTimer.Stop(); + + // Update all category panel widths first + foreach (var panel in categoryPanels.Values) + { + panel.Width = mainPanel.ClientSize.Width; + panel.UpdateLayout(); + } + + // Then recalculate the overall layout + RecalculateLayout(); + }; + } + + protected override void OnResize(EventArgs e) + { + base.OnResize(e); + if (resizeTimer != null) + { + resizeTimer.Stop(); + resizeTimer.Start(); + } + } + + /// + /// Refreshes the toolbox with available nodes from the context + /// + public void RefreshNodes() + { + mainPanel.Controls.Clear(); + categoryPanels.Clear(); + + if (Context == null) return; + + // Get all nodes and group by menu path + MethodInfo[] methods = Context.GetType().GetMethods(); + var nodesByMenu = methods + .Where(m => m.GetCustomAttributes(typeof(NodeAttribute), false).Length > 0) + .Select(m => new + { + Method = m, + Attribute = m.GetCustomAttributes(typeof(NodeAttribute), false) + .Cast() + .FirstOrDefault() + }) + .Where(x => x.Attribute != null) + .GroupBy(x => string.IsNullOrEmpty(x.Attribute.Menu) ? "General" : x.Attribute.Menu) + .OrderBy(g => g.Key); + + int yPosition = 0; + + foreach (var categoryGroup in nodesByMenu) + { + // Create category panel + CategoryPanel categoryPanel = new CategoryPanel(categoryGroup.Key) + { + Location = new Point(0, yPosition), + Width = mainPanel.ClientSize.Width, + Anchor = AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Top + }; + + // Add nodes to category + foreach (var nodeInfo in categoryGroup.OrderBy(x => x.Attribute.Name)) + { + NodeToolboxItem item = new NodeToolboxItem + { + Method = nodeInfo.Method, + Attribute = nodeInfo.Attribute, + DisplayName = nodeInfo.Attribute.Name ?? nodeInfo.Method.Name + }; + + MiniNodeControl miniNode = new MiniNodeControl(item, Context) + { + Margin = new Padding(5) + }; + + miniNode.MouseDown += MiniNode_MouseDown; + miniNode.MouseMove += MiniNode_MouseMove; + + categoryPanel.AddNode(miniNode); + } + + categoryPanel.CollapsedChanged += (sender, e) => RecalculateLayout(); + categoryPanels[categoryGroup.Key] = categoryPanel; + mainPanel.Controls.Add(categoryPanel); + } + + // Defer layout calculation to ensure controls are properly sized + Timer layoutTimer = new Timer(); + layoutTimer.Interval = 10; + layoutTimer.Tick += (s, e) => + { + layoutTimer.Stop(); + layoutTimer.Dispose(); + RecalculateLayout(); + + // Force update of all category panels + foreach (var panel in categoryPanels.Values) + { + panel.UpdateLayout(); + } + }; + layoutTimer.Start(); + } + + private void RecalculateLayout() + { + int yPosition = 0; + foreach (Control control in mainPanel.Controls) + { + if (control is CategoryPanel categoryPanel) + { + categoryPanel.Location = new Point(0, yPosition); + yPosition += categoryPanel.Height + 5; + } + } + } + + private Point dragStartPoint; + private MiniNodeControl draggingNode; + + private void MiniNode_MouseDown(object sender, MouseEventArgs e) + { + if (e.Button == MouseButtons.Left) + { + draggingNode = sender as MiniNodeControl; + dragStartPoint = e.Location; + } + } + + private void MiniNode_MouseMove(object sender, MouseEventArgs e) + { + if (draggingNode != null && e.Button == MouseButtons.Left) + { + // Check if we've moved enough to start a drag operation + int deltaX = Math.Abs(e.Location.X - dragStartPoint.X); + int deltaY = Math.Abs(e.Location.Y - dragStartPoint.Y); + + if (deltaX > 5 || deltaY > 5) + { + // Notify any connected NodesControl about drag start + OnDragPreviewStart?.Invoke(draggingNode.Item); + + // Start drag operation + NodeDragData dragData = new NodeDragData + { + MethodName = draggingNode.Item.Method.Name, + DisplayName = draggingNode.Item.DisplayName + }; + + try + { + draggingNode.DoDragDrop(dragData, DragDropEffects.Copy); + } + finally + { + // Notify drag end + OnDragPreviewEnd?.Invoke(); + draggingNode = null; + } + } + } + } + } + + /// + /// A collapsible category panel that contains a flow layout of nodes + /// + public class CategoryPanel : Panel + { + private Button headerButton; + private FlowLayoutPanel flowPanel; + private bool isCollapsed = true; // Start collapsed by default + private const int HeaderHeight = 25; + + public event EventHandler CollapsedChanged; + + public CategoryPanel(string categoryName) + { + BackColor = Color.White; + BorderStyle = BorderStyle.FixedSingle; + + // Create header button + headerButton = new Button + { + Text = (isCollapsed ? "▶ " : "▼ ") + categoryName, + Dock = DockStyle.Top, + Height = HeaderHeight, + FlatStyle = FlatStyle.Flat, + TextAlign = ContentAlignment.MiddleLeft, + BackColor = Color.FromArgb(220, 220, 220), + ForeColor = Color.Black, + Font = new Font(SystemFonts.DefaultFont, FontStyle.Bold) + }; + headerButton.FlatAppearance.BorderSize = 0; + headerButton.Click += HeaderButton_Click; + + // Create flow panel for nodes + flowPanel = new FlowLayoutPanel + { + Location = new Point(0, HeaderHeight), + Width = Width, + AutoScroll = false, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + WrapContents = true, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(5), + BackColor = Color.White, + Anchor = AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Top, + Visible = !isCollapsed // Start hidden if collapsed + }; + + Controls.Add(flowPanel); + Controls.Add(headerButton); + + // Set initial height based on collapsed state + Height = isCollapsed ? HeaderHeight : HeaderHeight + 10; + } + + private void HeaderButton_Click(object sender, EventArgs e) + { + isCollapsed = !isCollapsed; + flowPanel.Visible = !isCollapsed; + headerButton.Text = (isCollapsed ? "▶ " : "▼ ") + headerButton.Text.Substring(2); + UpdateHeight(); + CollapsedChanged?.Invoke(this, EventArgs.Empty); + } + + public void AddNode(MiniNodeControl node) + { + flowPanel.Controls.Add(node); + UpdateHeight(); + } + + public void UpdateLayout() + { + if (flowPanel == null) return; + + // Update flowPanel width to match container + int newWidth = Math.Max(Width - 2, 50); // Minimum width + flowPanel.Width = newWidth; + flowPanel.MaximumSize = new Size(newWidth, 0); + + // Force a layout pass before calculating height + flowPanel.SuspendLayout(); + flowPanel.PerformLayout(); + flowPanel.ResumeLayout(true); + + // Small delay to ensure all controls are properly positioned + Application.DoEvents(); + + UpdateHeight(); + + // Fire CollapsedChanged to trigger parent layout update + CollapsedChanged?.Invoke(this, EventArgs.Empty); + } + + private void UpdateHeight() + { + if (isCollapsed) + { + Height = HeaderHeight; + } + else + { + // Force layout update first + flowPanel.PerformLayout(); + + if (flowPanel.Controls.Count > 0) + { + // Calculate height based on actual control positions + int maxBottom = HeaderHeight + 10; // Start with header + padding + + foreach (Control control in flowPanel.Controls) + { + // Account for control's position relative to flowPanel + flowPanel's position + int controlBottom = flowPanel.Top + control.Bottom + flowPanel.Padding.Bottom; + maxBottom = Math.Max(maxBottom, controlBottom); + } + + Height = maxBottom + 5; // Add extra padding at bottom + } + else + { + Height = HeaderHeight + 10; // Minimal height when empty + } + } + } + + protected override void OnResize(EventArgs e) + { + base.OnResize(e); + + // Update flow panel width to match new container width + if (flowPanel != null) + { + flowPanel.Width = Width - 2; + flowPanel.MaximumSize = new Size(Width - 2, 0); + + // Use a small delay to ensure resize is complete + Timer updateTimer = new Timer(); + updateTimer.Interval = 10; + updateTimer.Tick += (s, args) => + { + updateTimer.Stop(); + updateTimer.Dispose(); + UpdateLayout(); + }; + updateTimer.Start(); + } + } + } + + /// + /// A mini node control that renders a small version of a node + /// + public class MiniNodeControl : Control + { + public NodeToolboxItem Item { get; private set; } + private INodesContext context; + private float scale = 0.7f; // Increased from 0.5f for better visibility + + public MiniNodeControl(NodeToolboxItem item, INodesContext context) + { + Item = item; + this.context = context; + + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + + // Calculate size based on actual node dimensions + SizeF nodeSize = CalculateNodeSize(); + // Add 2 pixels padding to ensure borders aren't cut off + Size = new Size((int)(nodeSize.Width * scale) + 2, (int)(nodeSize.Height * scale) + 2); + + Cursor = Cursors.Hand; + } + + public SizeF CalculateNodeSize() + { + // Create a temporary NodeVisual to get accurate sizing + NodeVisual tempNode = new NodeVisual(); + tempNode.Type = Item.Method; + tempNode.Name = Item.DisplayName; + tempNode.Callable = Item.Attribute.IsCallable; + tempNode.ExecInit = Item.Attribute.IsExecutionInitiator; + tempNode.CustomWidth = Item.Attribute.Width > 0 ? Item.Attribute.Width : -1; + tempNode.CustomHeight = Item.Attribute.Height > 0 ? Item.Attribute.Height : -1; + + return tempNode.GetNodeBounds(); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + + Graphics g = e.Graphics; + g.SmoothingMode = SmoothingMode.AntiAlias; + g.InterpolationMode = InterpolationMode.HighQualityBilinear; + + // Save the original transform + var originalTransform = g.Transform; + + // Offset by 1 pixel to account for border padding + g.TranslateTransform(1, 1); + + // Apply scaling + g.ScaleTransform(scale, scale); + + // Create a temporary NodeVisual for rendering + NodeVisual tempNode = new NodeVisual(); + tempNode.Type = Item.Method; + tempNode.Name = Item.DisplayName; + tempNode.Callable = Item.Attribute.IsCallable; + tempNode.ExecInit = Item.Attribute.IsExecutionInitiator; + tempNode.CustomWidth = Item.Attribute.Width > 0 ? Item.Attribute.Width : -1; + tempNode.CustomHeight = Item.Attribute.Height > 0 ? Item.Attribute.Height : -1; + tempNode.X = 0; + tempNode.Y = 0; + + // Draw the node using NodeVisual's Draw method + Point mousePos = new Point(-100, -100); // Off-screen so nothing is highlighted + tempNode.Draw(g, mousePos, MouseButtons.None); + + // Reset transform + g.Transform = originalTransform; + + // Draw hover effect border on top + if (ClientRectangle.Contains(PointToClient(MousePosition))) + { + using (Pen hoverPen = new Pen(Color.FromArgb(100, Color.Blue), 2)) + { + g.DrawRectangle(hoverPen, new Rectangle(0, 0, Width - 1, Height - 1)); + } + } + } + + protected override void OnMouseEnter(EventArgs e) + { + base.OnMouseEnter(e); + Invalidate(); + } + + protected override void OnMouseLeave(EventArgs e) + { + base.OnMouseLeave(e); + Invalidate(); + } + } + +} \ No newline at end of file diff --git a/NodeEditor/NodesControl.cs b/NodeEditor/NodesControl.cs index eb7655b..27a0111 100644 --- a/NodeEditor/NodesControl.cs +++ b/NodeEditor/NodesControl.cs @@ -20,6 +20,7 @@ using System.Data; using System.Drawing; using System.Drawing.Drawing2D; +using System.Drawing.Imaging; using System.IO; using System.Linq; using System.Reflection; @@ -60,6 +61,10 @@ internal class NodeToken private bool rebuildConnectionDictionary = true; private Dictionary connectionDictionary = new Dictionary(); + // Drag preview support + private NodeVisual dragPreviewNode = null; + private Point dragPreviewLocation; + /// /// Context of the editor. You should set here an instance that implements INodesContext interface. /// In context you should define your nodes (methods decorated by Node attribute). @@ -137,6 +142,13 @@ public NodesControl() SetStyle(ControlStyles.Selectable, true); // Enable double buffering for smoother rendering SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); + + // Enable drag and drop + AllowDrop = true; + DragEnter += NodesControl_DragEnter; + DragOver += NodesControl_DragOver; + DragDrop += NodesControl_DragDrop; + DragLeave += NodesControl_DragLeave; } private void ContextOnFeedbackInfo(string message, NodeVisual nodeVisual, FeedbackType type, object tag, bool breakExecution) @@ -288,6 +300,27 @@ private void NodesControl_Paint(object sender, PaintEventArgs e) } } + // Draw drag preview if active + if (dragPreviewNode != null) + { + // Draw the preview node with a slight visual difference (e.g., dashed border) + dragPreviewNode.Draw(e.Graphics, Point.Round(transformedMouse), MouseButtons.None); + + // Draw a dashed outline to indicate it's a preview + SizeF nodeSize = dragPreviewNode.GetNodeBounds(); + using (Pen dashPen = new Pen(Color.DodgerBlue, 2 / zoomLevel)) + { + dashPen.DashStyle = DashStyle.Dash; + RectangleF previewRect = new RectangleF( + dragPreviewNode.X - 2, + dragPreviewNode.Y - 2, + nodeSize.Width + 4, + nodeSize.Height + 4 + ); + e.Graphics.DrawRectangle(dashPen, Rectangle.Round(previewRect)); + } + } + // Restore transform for UI elements e.Graphics.Transform = originalTransform; @@ -2112,5 +2145,134 @@ public void DeserializeFromJson(string json) rebuildConnectionDictionary = true; Refresh(); } + + private void NodesControl_DragEnter(object sender, DragEventArgs e) + { + if (e.Data.GetDataPresent(typeof(NodeDragData))) + { + e.Effect = DragDropEffects.Copy; + } + else + { + e.Effect = DragDropEffects.None; + } + } + + private void NodesControl_DragOver(object sender, DragEventArgs e) + { + if (e.Data.GetDataPresent(typeof(NodeDragData))) + { + e.Effect = DragDropEffects.Copy; + + // Update drag preview position if active + if (dragPreviewNode != null) + { + // Convert screen coordinates to client coordinates, then to world coordinates + Point clientPoint = PointToClient(new Point(e.X, e.Y)); + PointF worldPoint = ScreenToWorld(clientPoint); + + // Store old position for socket update + float oldX = dragPreviewNode.X; + float oldY = dragPreviewNode.Y; + + // Update preview position + dragPreviewNode.X = worldPoint.X; + dragPreviewNode.Y = worldPoint.Y; + + // Update socket positions with the delta movement + float dx = worldPoint.X - oldX; + float dy = worldPoint.Y - oldY; + dragPreviewNode.UpdateSocketPositions(dx, dy); + + // Trigger repaint + Invalidate(); + } + } + else + { + e.Effect = DragDropEffects.None; + } + } + + private void NodesControl_DragDrop(object sender, DragEventArgs e) + { + if (e.Data.GetDataPresent(typeof(NodeDragData))) + { + NodeDragData dragData = e.Data.GetData(typeof(NodeDragData)) as NodeDragData; + if (dragData?.MethodName != null) + { + // Convert screen coordinates to world coordinates + Point clientPoint = PointToClient(new Point(e.X, e.Y)); + PointF worldPoint = ScreenToWorld(clientPoint); + + // Create a new node using the method from the dragged item + AddNodeByMethodName(dragData.MethodName, worldPoint.X, worldPoint.Y); + } + } + } + + private void NodesControl_DragLeave(object sender, EventArgs e) + { + // Hide preview when drag leaves the control + if (dragPreviewNode != null) + { + dragPreviewNode.X = -1000; + dragPreviewNode.Y = -1000; + Invalidate(); + } + } + + /// + /// Starts showing a drag preview for a node being dragged from toolbox + /// + public void StartDragPreview(NodeToolboxItem item) + { + if (item?.Method == null) return; + + // Create a preview node + dragPreviewNode = new NodeVisual(); + dragPreviewNode.Type = item.Method; + dragPreviewNode.Name = item.DisplayName; + dragPreviewNode.Callable = item.Attribute.IsCallable; + dragPreviewNode.ExecInit = item.Attribute.IsExecutionInitiator; + dragPreviewNode.CustomWidth = item.Attribute.Width > 0 ? item.Attribute.Width : -1; + dragPreviewNode.CustomHeight = item.Attribute.Height > 0 ? item.Attribute.Height : -1; + + // Position it initially off-screen + dragPreviewNode.X = -1000; + dragPreviewNode.Y = -1000; + + // Force socket generation by calling GetSockets + dragPreviewNode.GetSockets(); + } + + /// + /// Stops showing the drag preview + /// + public void StopDragPreview() + { + dragPreviewNode = null; + Invalidate(); // Redraw to remove preview + } + } + + /// + /// Represents a draggable node item from the toolbox (moved here for shared access) + /// + public class NodeToolboxItem + { + public MethodInfo Method { get; set; } + public NodeAttribute Attribute { get; set; } + public string DisplayName { get; set; } + } + + /// + /// Data object for node drag-and-drop operations + /// + [Serializable] + public class NodeDragData + { + public string MethodName { get; set; } + public string DisplayName { get; set; } } } diff --git a/SampleCommon/NodeToolboxPanel.Designer.cs b/SampleCommon/NodeToolboxPanel.Designer.cs new file mode 100644 index 0000000..bec15a4 --- /dev/null +++ b/SampleCommon/NodeToolboxPanel.Designer.cs @@ -0,0 +1,44 @@ +namespace SampleCommon +{ + partial class NodeToolboxPanel + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + this.SuspendLayout(); + // + // NodeToolboxPanel + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.Name = "NodeToolboxPanel"; + this.Size = new System.Drawing.Size(200, 400); + this.ResumeLayout(false); + } + + #endregion + } +} \ No newline at end of file diff --git a/SampleCommon/NodeToolboxPanel.cs b/SampleCommon/NodeToolboxPanel.cs new file mode 100644 index 0000000..783b39d --- /dev/null +++ b/SampleCommon/NodeToolboxPanel.cs @@ -0,0 +1,525 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Linq; +using System.Reflection; +using System.Windows.Forms; +using NodeEditor; + +namespace SampleCommon +{ + /// + /// A toolbox panel that displays available node types with collapsible categories and drag-and-drop support + /// + public partial class NodeToolboxPanel : UserControl + { + private Dictionary categoryPanels = new Dictionary(); + private Panel mainPanel; + + public INodesContext Context { get; set; } + + public NodeToolboxPanel() + { + InitializeComponent(); + SetupMainPanel(); + } + + private void SetupMainPanel() + { + mainPanel = new Panel + { + Dock = DockStyle.Fill, + AutoScroll = true, + BackColor = Color.FromArgb(240, 240, 240) + }; + + Controls.Add(mainPanel); + } + + /// + /// Refreshes the toolbox with available nodes from the context + /// + public void RefreshNodes() + { + mainPanel.Controls.Clear(); + categoryPanels.Clear(); + + if (Context == null) return; + + // Get all nodes and group by category + MethodInfo[] methods = Context.GetType().GetMethods(); + var nodesByCategory = methods + .Where(m => m.GetCustomAttributes(typeof(NodeAttribute), false).Length > 0) + .Select(m => new + { + Method = m, + Attribute = m.GetCustomAttributes(typeof(NodeAttribute), false) + .Cast() + .FirstOrDefault() + }) + .Where(x => x.Attribute != null) + .GroupBy(x => x.Attribute.Category ?? "General") + .OrderBy(g => g.Key); + + int yPosition = 0; + + foreach (var categoryGroup in nodesByCategory) + { + // Create category panel + CategoryPanel categoryPanel = new CategoryPanel(categoryGroup.Key) + { + Location = new Point(0, yPosition), + Width = mainPanel.ClientSize.Width, + Anchor = AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Top + }; + + // Add nodes to category + foreach (var nodeInfo in categoryGroup.OrderBy(x => x.Attribute.Name)) + { + NodeToolboxItem item = new NodeToolboxItem + { + Method = nodeInfo.Method, + Attribute = nodeInfo.Attribute, + DisplayName = nodeInfo.Attribute.Name ?? nodeInfo.Method.Name + }; + + MiniNodeControl miniNode = new MiniNodeControl(item, Context) + { + Margin = new Padding(5) + }; + + miniNode.MouseDown += MiniNode_MouseDown; + miniNode.MouseMove += MiniNode_MouseMove; + + categoryPanel.AddNode(miniNode); + } + + categoryPanel.CollapsedChanged += (sender, e) => RecalculateLayout(); + categoryPanels[categoryGroup.Key] = categoryPanel; + mainPanel.Controls.Add(categoryPanel); + + yPosition += categoryPanel.Height + 5; + } + } + + private void RecalculateLayout() + { + int yPosition = 0; + foreach (Control control in mainPanel.Controls) + { + if (control is CategoryPanel categoryPanel) + { + categoryPanel.Location = new Point(0, yPosition); + yPosition += categoryPanel.Height + 5; + } + } + } + + private Point dragStartPoint; + private MiniNodeControl draggingNode; + + private void MiniNode_MouseDown(object sender, MouseEventArgs e) + { + if (e.Button == MouseButtons.Left) + { + draggingNode = sender as MiniNodeControl; + dragStartPoint = e.Location; + } + } + + private void MiniNode_MouseMove(object sender, MouseEventArgs e) + { + if (draggingNode != null && e.Button == MouseButtons.Left) + { + // Check if we've moved enough to start a drag operation + int deltaX = Math.Abs(e.Location.X - dragStartPoint.X); + int deltaY = Math.Abs(e.Location.Y - dragStartPoint.Y); + + if (deltaX > 5 || deltaY > 5) + { + // Start drag operation + NodeDragData dragData = new NodeDragData + { + MethodName = draggingNode.Item.Method.Name, + DisplayName = draggingNode.Item.DisplayName + }; + + draggingNode.DoDragDrop(dragData, DragDropEffects.Copy); + draggingNode = null; + } + } + } + } + + /// + /// A collapsible category panel that contains a flow layout of nodes + /// + public class CategoryPanel : Panel + { + private Button headerButton; + private FlowLayoutPanel flowPanel; + private bool isCollapsed = false; + private const int HeaderHeight = 25; + + public event EventHandler CollapsedChanged; + + public CategoryPanel(string categoryName) + { + BackColor = Color.White; + BorderStyle = BorderStyle.FixedSingle; + + // Create header button + headerButton = new Button + { + Text = (isCollapsed ? "▶ " : "▼ ") + categoryName, + Dock = DockStyle.Top, + Height = HeaderHeight, + FlatStyle = FlatStyle.Flat, + TextAlign = ContentAlignment.MiddleLeft, + BackColor = Color.FromArgb(220, 220, 220), + ForeColor = Color.Black, + Font = new Font(SystemFonts.DefaultFont, FontStyle.Bold) + }; + headerButton.FlatAppearance.BorderSize = 0; + headerButton.Click += HeaderButton_Click; + + // Create flow panel for nodes + flowPanel = new FlowLayoutPanel + { + Dock = DockStyle.Fill, + AutoScroll = false, + WrapContents = true, + FlowDirection = FlowDirection.LeftToRight, + Padding = new Padding(5), + BackColor = Color.White + }; + + Controls.Add(flowPanel); + Controls.Add(headerButton); + + UpdateHeight(); + } + + private void HeaderButton_Click(object sender, EventArgs e) + { + isCollapsed = !isCollapsed; + flowPanel.Visible = !isCollapsed; + headerButton.Text = (isCollapsed ? "▶ " : "▼ ") + headerButton.Text.Substring(2); + UpdateHeight(); + CollapsedChanged?.Invoke(this, EventArgs.Empty); + } + + public void AddNode(MiniNodeControl node) + { + flowPanel.Controls.Add(node); + UpdateHeight(); + } + + private void UpdateHeight() + { + if (isCollapsed) + { + Height = HeaderHeight; + } + else + { + // Calculate required height based on flow panel contents + int maxY = HeaderHeight + 10; + foreach (Control control in flowPanel.Controls) + { + maxY = Math.Max(maxY, control.Bottom + flowPanel.Padding.Bottom); + } + Height = maxY + 5; + } + } + + protected override void OnResize(EventArgs e) + { + base.OnResize(e); + UpdateHeight(); + } + } + + /// + /// A mini node control that renders a small version of a node + /// + public class MiniNodeControl : Control + { + public NodeToolboxItem Item { get; private set; } + private INodesContext context; + private float scale = 0.5f; // Scale factor for mini nodes + + public MiniNodeControl(NodeToolboxItem item, INodesContext context) + { + Item = item; + this.context = context; + + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | + ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw, true); + + // Calculate size based on actual node dimensions + SizeF nodeSize = CalculateNodeSize(); + Size = new Size((int)(nodeSize.Width * scale), (int)(nodeSize.Height * scale)); + + Cursor = Cursors.Hand; + } + + private SizeF CalculateNodeSize() + { + // Get parameters to determine socket count + ParameterInfo[] parameters = Item.Method.GetParameters(); + ParameterInfo[] inputs = parameters.Where(p => !p.IsOut).ToArray(); + ParameterInfo[] outputs = parameters.Where(p => p.IsOut).ToArray(); + + // Calculate text widths + float maxInputWidth = 0; + float maxOutputWidth = 0; + + foreach (var input in inputs) + { + string displayName = ToTitleCase(input.Name); + SizeF textSize = TextRenderer.MeasureText(displayName, SystemFonts.SmallCaptionFont); + maxInputWidth = Math.Max(maxInputWidth, textSize.Width); + } + + foreach (var output in outputs) + { + string displayName = ToTitleCase(output.Name); + SizeF textSize = TextRenderer.MeasureText(displayName, SystemFonts.SmallCaptionFont); + maxOutputWidth = Math.Max(maxOutputWidth, textSize.Width); + } + + // Add execution sockets if callable + if (Item.Attribute.IsCallable) + { + if (!Item.Attribute.IsExecutionInitiator) + { + SizeF textSize = TextRenderer.MeasureText("Enter", SystemFonts.SmallCaptionFont); + maxInputWidth = Math.Max(maxInputWidth, textSize.Width); + } + SizeF exitSize = TextRenderer.MeasureText("Exit", SystemFonts.SmallCaptionFont); + maxOutputWidth = Math.Max(maxOutputWidth, exitSize.Width); + } + + // Calculate node name width + SizeF nameSize = TextRenderer.MeasureText(Item.DisplayName, SystemFonts.DefaultFont); + float nameWidth = nameSize.Width + 10; + + // Constants from NodeVisual + const float minWidth = 150; + const float socketHeight = 16; + const float socketPadding = 2; + const float padding = 20; + const float edgePadding = 10; + const float headerHeight = 18; + const float componentPadding = 1; + + // Calculate width + float socketBasedWidth = socketHeight + socketPadding + maxInputWidth + padding + + maxOutputWidth + socketPadding + socketHeight + edgePadding * 2; + float width = Math.Max(Math.Max(minWidth, nameWidth), socketBasedWidth); + + // Calculate height + int inputCount = inputs.Length; + int outputCount = outputs.Length; + if (Item.Attribute.IsCallable) + { + inputCount++; + outputCount++; + } + float height = headerHeight + Math.Max(inputCount * (socketHeight + componentPadding), + outputCount * (socketHeight + componentPadding)) + componentPadding * 2f; + + // Use custom dimensions if specified + if (Item.Attribute.Width > 0) + width = Item.Attribute.Width; + if (Item.Attribute.Height > 0) + height = Item.Attribute.Height; + + return new SizeF(width, height); + } + + private string ToTitleCase(string input) + { + if (string.IsNullOrEmpty(input)) return input; + + // Split on capital letters and underscores + string result = System.Text.RegularExpressions.Regex.Replace(input, "([A-Z])", " $1").Trim(); + result = result.Replace("_", " "); + + // Convert to title case + return System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(result.ToLower()); + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + + Graphics g = e.Graphics; + g.SmoothingMode = SmoothingMode.AntiAlias; + g.InterpolationMode = InterpolationMode.HighQualityBilinear; + + // Apply scaling + g.ScaleTransform(scale, scale); + + // Draw the node using similar logic to NodeVisual + RectangleF nodeRect = new RectangleF(0, 0, Width / scale, Height / scale); + RectangleF headerRect = new RectangleF(0, 0, nodeRect.Width, 18); + + // Node background + using (Brush nodeBrush = new SolidBrush(Color.LightCyan)) + { + g.FillRectangle(nodeBrush, nodeRect); + } + + // Header + using (Brush headerBrush = new SolidBrush(Color.Aquamarine)) + { + g.FillRectangle(headerBrush, headerRect); + } + + // Borders + using (Pen borderPen = new Pen(Color.Black, 1)) + { + g.DrawRectangle(borderPen, Rectangle.Round(nodeRect)); + g.DrawRectangle(borderPen, Rectangle.Round(headerRect)); + } + + // Node name + using (Font smallFont = new Font(SystemFonts.DefaultFont.FontFamily, SystemFonts.DefaultFont.Size * 0.9f)) + { + g.DrawString(Item.DisplayName, smallFont, Brushes.Black, new PointF(3, 2)); + } + + // Draw sockets + DrawSockets(g); + + // Reset transform + g.ResetTransform(); + + // Draw hover effect + if (ClientRectangle.Contains(PointToClient(MousePosition))) + { + using (Pen hoverPen = new Pen(Color.FromArgb(100, Color.Blue), 2)) + { + g.DrawRectangle(hoverPen, new Rectangle(0, 0, Width - 1, Height - 1)); + } + } + } + + private void DrawSockets(Graphics g) + { + ParameterInfo[] parameters = Item.Method.GetParameters(); + ParameterInfo[] inputs = parameters.Where(p => !p.IsOut).ToArray(); + ParameterInfo[] outputs = parameters.Where(p => p.IsOut).ToArray(); + + const float socketSize = 8; + const float headerHeight = 18; + const float socketSpacing = 17; + float startY = headerHeight + 5; + + Font socketFont = new Font(SystemFonts.SmallCaptionFont.FontFamily, + SystemFonts.SmallCaptionFont.Size * 0.85f); + + // Draw execution sockets if callable + int inputOffset = 0; + int outputOffset = 0; + + if (Item.Attribute.IsCallable) + { + // Input execution socket + if (!Item.Attribute.IsExecutionInitiator) + { + RectangleF execInRect = new RectangleF(1, startY, socketSize, socketSize); + g.FillRectangle(Brushes.White, execInRect); + g.DrawRectangle(Pens.Black, Rectangle.Round(execInRect)); + g.DrawString("Enter", socketFont, Brushes.Black, new PointF(socketSize + 3, startY - 2)); + inputOffset = 1; + } + + // Output execution socket + RectangleF execOutRect = new RectangleF(Width / scale - socketSize - 1, startY, socketSize, socketSize); + g.FillRectangle(Brushes.White, execOutRect); + g.DrawRectangle(Pens.Black, Rectangle.Round(execOutRect)); + + SizeF exitSize = g.MeasureString("Exit", socketFont); + g.DrawString("Exit", socketFont, Brushes.Black, + new PointF(Width / scale - socketSize - 3 - exitSize.Width, startY - 2)); + outputOffset = 1; + } + + // Draw input sockets + for (int i = 0; i < inputs.Length; i++) + { + float y = startY + (i + inputOffset) * socketSpacing; + Color socketColor = GetSocketColor(inputs[i].ParameterType); + + using (Brush socketBrush = new SolidBrush(socketColor)) + { + g.FillEllipse(socketBrush, 1, y, socketSize, socketSize); + } + g.DrawEllipse(Pens.Black, 1, y, socketSize, socketSize); + + string displayName = ToTitleCase(inputs[i].Name); + g.DrawString(displayName, socketFont, Brushes.Black, new PointF(socketSize + 3, y - 2)); + } + + // Draw output sockets + for (int i = 0; i < outputs.Length; i++) + { + float y = startY + (i + outputOffset) * socketSpacing; + Type outputType = outputs[i].ParameterType.GetElementType() ?? outputs[i].ParameterType; + Color socketColor = GetSocketColor(outputType); + + using (Brush socketBrush = new SolidBrush(socketColor)) + { + g.FillEllipse(socketBrush, Width / scale - socketSize - 1, y, socketSize, socketSize); + } + g.DrawEllipse(Pens.Black, Width / scale - socketSize - 1, y, socketSize, socketSize); + + string displayName = ToTitleCase(outputs[i].Name); + SizeF textSize = g.MeasureString(displayName, socketFont); + g.DrawString(displayName, socketFont, Brushes.Black, + new PointF(Width / scale - socketSize - 3 - textSize.Width, y - 2)); + } + + socketFont.Dispose(); + } + + private Color GetSocketColor(Type type) + { + // Match the color scheme used in SocketVisual + if (type == typeof(int) || type == typeof(float) || type == typeof(double) || type == typeof(decimal)) + return Color.LightGreen; + else if (type == typeof(string)) + return Color.Yellow; + else if (type == typeof(bool)) + return Color.Red; + else + return Color.LightBlue; + } + + protected override void OnMouseEnter(EventArgs e) + { + base.OnMouseEnter(e); + Invalidate(); + } + + protected override void OnMouseLeave(EventArgs e) + { + base.OnMouseLeave(e); + Invalidate(); + } + } + + /// + /// Represents a draggable node item in the toolbox + /// + public class NodeToolboxItem + { + public MethodInfo Method { get; set; } + public NodeAttribute Attribute { get; set; } + public string DisplayName { get; set; } + } +} \ No newline at end of file From 90171923d34fa9b97e2905055b0bd4393f50aae5 Mon Sep 17 00:00:00 2001 From: Brian Whitenack Date: Mon, 15 Sep 2025 12:18:25 -0500 Subject: [PATCH 8/8] feat: Hightlighting Compatible Sockets --- .claude/settings.local.json | 4 ++- NodeEditor/NodesControl.cs | 58 +++++++++++++++++++++++++++++++++++++ NodeEditor/SocketVisual.cs | 22 ++++++++++++-- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0d0afbe..4adf50f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,9 @@ "Bash(\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional\\MSBuild\\Current\\Bin\\amd64\\MSBuild.exe\" \"NodeEditor\\NodeEditor.csproj\")", "Bash(\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional\\MSBuild\\Current\\Bin\\amd64\\MSBuild.exe\" NodeEditorWinforms.sln)", "Bash(del:*)", - "Bash(cmd /c:*)" + "Bash(cmd /c:*)", + "Bash(\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional\\MSBuild\\Current\\Bin\\amd64\\MSBuild.exe\" NodeEditorWinforms.sln /t:Build /p:Configuration=Debug /v:minimal)", + "Bash(\"C:\\Program Files\\Microsoft Visual Studio\\2022\\Professional\\MSBuild\\Current\\Bin\\amd64\\MSBuild.exe\" NodeEditorWinforms.sln /t:Build /p:Configuration=Debug)" ], "deny": [], "ask": [] diff --git a/NodeEditor/NodesControl.cs b/NodeEditor/NodesControl.cs index 27a0111..eb5f848 100644 --- a/NodeEditor/NodesControl.cs +++ b/NodeEditor/NodesControl.cs @@ -532,6 +532,9 @@ private bool TryStartConnectionDrag(Point location) } } + // Highlight compatible sockets when dragging a connection + HighlightCompatibleSockets(dragSocket); + mdown = true; lastmpos = PointToScreen(location); @@ -627,6 +630,9 @@ private NodeVisual TryHandleSocketInteraction(Point location, NodeVisual targetN graph.Connections.Remove(connection); rebuildConnectionDictionary = true; + // Highlight compatible sockets when disconnecting and redragging + HighlightCompatibleSockets(dragSocket); + // Handle type propagation after disconnection - use unified propagation if (connection != null) { @@ -640,6 +646,9 @@ private NodeVisual TryHandleSocketInteraction(Point location, NodeVisual targetN dragSocketNode = nodeWhole; } + // Highlight compatible sockets when starting a connection drag + HighlightCompatibleSockets(dragSocket); + // Convert to world coordinates for connection dragging PointF worldLoc = ScreenToWorld(location); dragConnectionBegin = worldLoc; @@ -726,6 +735,52 @@ private bool IsConnectable(SocketVisual a, SocketVisual b) return TypePropagation.AreTypesCompatible(outputType, inputType); } + /// + /// Highlights all sockets compatible with the currently dragged socket + /// + private void HighlightCompatibleSockets(SocketVisual draggedSocket) + { + if (draggedSocket == null) return; + + // Go through all nodes and their sockets + foreach (NodeVisual node in graph.Nodes) + { + foreach (SocketVisual socket in node.GetSockets()) + { + // Skip sockets on the same node as the dragged socket + if (node == dragSocketNode) + { + socket.IsHighlightedAsCompatible = false; + continue; + } + + // Only highlight opposite type sockets (input vs output) + if (socket.Input == draggedSocket.Input) + { + socket.IsHighlightedAsCompatible = false; + continue; + } + + // Check if the sockets are compatible + socket.IsHighlightedAsCompatible = IsConnectable(draggedSocket, socket); + } + } + } + + /// + /// Clears all socket highlighting + /// + private void ClearSocketHighlighting() + { + foreach (NodeVisual node in graph.Nodes) + { + foreach (SocketVisual socket in node.GetSockets()) + { + socket.IsHighlightedAsCompatible = false; + } + } + } + private Type TypeResolver(Assembly assembly, string name, bool inh) { if (assembly == null) assembly = ResolveAssembly(name); @@ -867,6 +922,9 @@ private void NodesControl_MouseUp(object sender, MouseEventArgs e) } } + // Clear socket highlighting when connection drag ends + ClearSocketHighlighting(); + dragSocket = null; mdown = false; needRepaint = true; diff --git a/NodeEditor/SocketVisual.cs b/NodeEditor/SocketVisual.cs index af823c3..901c720 100644 --- a/NodeEditor/SocketVisual.cs +++ b/NodeEditor/SocketVisual.cs @@ -63,6 +63,11 @@ public string Name /// public Type RuntimeType { get; set; } + /// + /// Whether this socket should be highlighted as compatible during connection drag + /// + public bool IsHighlightedAsCompatible { get; set; } + public bool IsExecution { get { return Type.Name.Replace("&", "") == typeof (ExecutionPath).Name; } @@ -97,15 +102,28 @@ private string ToTitleCase(string name) } public void Draw(Graphics g, Point mouseLocation, MouseButtons mouseButtons) - { + { RectangleF socketRect = new RectangleF(X, Y, Width, Height); bool hover = socketRect.Contains(mouseLocation); Brush fontBrush = Brushes.Black; + // Highlight compatible sockets during connection drag + if (IsHighlightedAsCompatible) + { + fontBrush = Brushes.Green; + // Draw a green glow effect around compatible sockets + using (Pen glowPen = new Pen(Color.FromArgb(100, Color.LimeGreen), 3)) + { + RectangleF glowRect = new RectangleF(X - 2, Y - 2, Width + 4, Height + 4); + g.DrawEllipse(glowPen, glowRect); + } + } + if (hover) { socketRect.Inflate(4, 4); - fontBrush = Brushes.Blue; + if (!IsHighlightedAsCompatible) + fontBrush = Brushes.Blue; } g.SmoothingMode = SmoothingMode.HighSpeed;