From 35aa64fe4ecc8736db9838f72f0f585e30688c9b Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 28 Sep 2026 15:59:19 -0400 Subject: [PATCH] feat(posts): sectioned settings editor for CI Fiber post Replace the generic PropertyGrid for configs that opt in via PostSettingAttribute: a section list (Machine, Material, Program output, Macros) with labelled fields, help text, numeric ranges and an editable material-code table. Edits apply only when OK validates every field. Unannotated configs (Cincinnati CL, GravographIS) keep the PropertyGrid. --- .../PostSettings/PostSettingAttribute.cs | 56 +++ .../PostSettings/PostSettingsLayout.cs | 231 ++++++++++ .../PostSettings/PostSettingsLayoutTests.cs | 160 +++++++ OpenNest/Controls/PostSettingsEditor.cs | 410 ++++++++++++++++++ OpenNest/Forms/PostProcessorConfigForm.cs | 33 ++ .../CIFiberPostConfig.cs | 62 +++ docs/cincinnati-ci-fiber-post-output.md | 3 + 7 files changed, 955 insertions(+) create mode 100644 OpenNest.Core/PostSettings/PostSettingAttribute.cs create mode 100644 OpenNest.Core/PostSettings/PostSettingsLayout.cs create mode 100644 OpenNest.Tests/PostSettings/PostSettingsLayoutTests.cs create mode 100644 OpenNest/Controls/PostSettingsEditor.cs diff --git a/OpenNest.Core/PostSettings/PostSettingAttribute.cs b/OpenNest.Core/PostSettings/PostSettingAttribute.cs new file mode 100644 index 0000000..4ddd4c1 --- /dev/null +++ b/OpenNest.Core/PostSettings/PostSettingAttribute.cs @@ -0,0 +1,56 @@ +using System; + +namespace OpenNest.PostSettings +{ + /// + /// Places a post-processor config property in a section of the desktop + /// settings editor. A config type opts into the sectioned editor by marking + /// at least one property; unmarked configs keep the generic PropertyGrid. + /// Label and help text come from DisplayName and Description. + /// + [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] + public sealed class PostSettingAttribute : Attribute + { + public PostSettingAttribute(string section, int order = 0) + { + Section = section; + Order = order; + } + + public string Section { get; } + + public int Order { get; } + + /// Lower bound for numeric fields; NaN uses the kind's default. + public double Minimum { get; set; } = double.NaN; + + /// Upper bound for numeric fields; NaN uses the kind's default. + public double Maximum { get; set; } = double.NaN; + + /// Decimal places for decimal fields; negative uses the default. + public int DecimalPlaces { get; set; } = -1; + + /// Column header for the key of a name/value table. + public string KeyHeader { get; set; } + + /// Column header for the value of a name/value table. + public string ValueHeader { get; set; } + } + + /// Declares a settings section's order and description. + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public sealed class PostSettingsSectionAttribute : Attribute + { + public PostSettingsSectionAttribute(string name, int order) + { + Name = name; + Order = order; + } + + public string Name { get; } + + public int Order { get; } + + public string Description { get; set; } + } +} diff --git a/OpenNest.Core/PostSettings/PostSettingsLayout.cs b/OpenNest.Core/PostSettings/PostSettingsLayout.cs new file mode 100644 index 0000000..d350763 --- /dev/null +++ b/OpenNest.Core/PostSettings/PostSettingsLayout.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reflection; + +namespace OpenNest.PostSettings +{ + public enum PostSettingKind + { + Text, + Integer, + Decimal, + Boolean, + Choice, + StringMap, + } + + public sealed class PostSettingsField + { + internal PostSettingsField( + PropertyInfo property, + PostSettingKind kind, + PostSettingAttribute setting + ) + { + Property = property; + Kind = kind; + Label = + property.GetCustomAttribute()?.DisplayName + ?? property.Name; + Description = property.GetCustomAttribute()?.Description ?? ""; + + var (min, max) = DefaultRange(kind); + Minimum = setting != null && !double.IsNaN(setting.Minimum) ? setting.Minimum : min; + Maximum = setting != null && !double.IsNaN(setting.Maximum) ? setting.Maximum : max; + DecimalPlaces = + kind == PostSettingKind.Integer ? 0 + : setting != null && setting.DecimalPlaces >= 0 ? setting.DecimalPlaces + : 3; + KeyHeader = setting?.KeyHeader ?? "Name"; + ValueHeader = setting?.ValueHeader ?? "Value"; + } + + public PropertyInfo Property { get; } + + public string Name => Property.Name; + + public PostSettingKind Kind { get; } + + public string Label { get; } + + public string Description { get; } + + public double Minimum { get; } + + public double Maximum { get; } + + public int DecimalPlaces { get; } + + public string KeyHeader { get; } + + public string ValueHeader { get; } + + public string[] ChoiceNames => + Kind == PostSettingKind.Choice + ? Enum.GetNames(Property.PropertyType) + : Array.Empty(); + + public object GetValue(object config) => Property.GetValue(config); + + public void SetValue(object config, object value) => Property.SetValue(config, value); + + private static (double, double) DefaultRange(PostSettingKind kind) => + kind == PostSettingKind.Integer ? (int.MinValue, int.MaxValue) : (-1e9, 1e9); + } + + public sealed class PostSettingsSection + { + internal PostSettingsSection( + string name, + string description, + IReadOnlyList fields + ) + { + Name = name; + Description = description ?? ""; + Fields = fields; + } + + public string Name { get; } + + public string Description { get; } + + public IReadOnlyList Fields { get; } + } + + /// + /// Builds the sectioned settings layout for a post-processor config from its + /// metadata, without any UI dependency. + /// + public static class PostSettingsLayout + { + public const string OtherSection = "Other"; + + /// + /// Returns the ordered sections, or null when the config should use the + /// generic PropertyGrid: it has no , + /// or one of its editable properties has a type the editor cannot show. + /// Editable properties without the attribute go to the + /// so none become uneditable. + /// + public static IReadOnlyList TryBuild(Type configType) + { + if (configType == null) + return null; + + var properties = configType + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => + p.CanRead + && p.GetSetMethod() != null + && p.GetIndexParameters().Length == 0 + && p.GetCustomAttribute()?.Browsable != false + ) + .OrderBy(p => p.MetadataToken) + .ToList(); + + if (!properties.Any(p => p.GetCustomAttribute() != null)) + return null; + + var entries = new List<(string Section, int Order, int Index, PostSettingsField Field)>(); + for (var i = 0; i < properties.Count; i++) + { + var property = properties[i]; + var kind = KindOf(property.PropertyType); + if (kind == null) + return null; + + var setting = property.GetCustomAttribute(); + var section = string.IsNullOrWhiteSpace(setting?.Section) + ? OtherSection + : setting.Section; + entries.Add( + ( + section, + setting?.Order ?? int.MaxValue, + i, + new PostSettingsField(property, kind.Value, setting) + ) + ); + } + + var declared = configType + .GetCustomAttributes() + .GroupBy(a => a.Name, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal); + + return entries + .GroupBy(e => e.Section, StringComparer.Ordinal) + .Select(g => new + { + Name = g.Key, + Declared = declared.TryGetValue(g.Key, out var d) ? d : null, + FirstIndex = g.Min(e => e.Index), + Fields = g.OrderBy(e => e.Order).ThenBy(e => e.Index).Select(e => e.Field).ToList(), + }) + .OrderBy(s => s.Name == OtherSection && s.Declared == null ? 2 + : s.Declared != null ? 0 + : 1) + .ThenBy(s => s.Declared?.Order ?? 0) + .ThenBy(s => s.FirstIndex) + .Select(s => new PostSettingsSection(s.Name, s.Declared?.Description, s.Fields)) + .ToList(); + } + + /// + /// Builds a name/value map from edited table rows using the supplied key + /// comparer. Keys and values are trimmed; fully blank rows are skipped. + /// Throws naming the 1-based row for a + /// missing or duplicate name. + /// + public static Dictionary BuildMap( + IEnumerable> rows, + IEqualityComparer comparer + ) + { + var map = new Dictionary(comparer ?? StringComparer.Ordinal); + var row = 0; + + foreach (var entry in rows ?? Enumerable.Empty>()) + { + row++; + var key = entry.Key?.Trim() ?? ""; + var value = entry.Value?.Trim() ?? ""; + + if (key.Length == 0 && value.Length == 0) + continue; + if (key.Length == 0) + throw new FormatException($"Row {row}: a name is required."); + if (map.ContainsKey(key)) + throw new FormatException($"Row {row}: \"{key}\" is listed more than once."); + + map.Add(key, value); + } + + return map; + } + + private static PostSettingKind? KindOf(Type type) + { + if (type == typeof(string)) + return PostSettingKind.Text; + if (type == typeof(int)) + return PostSettingKind.Integer; + if (type == typeof(double)) + return PostSettingKind.Decimal; + if (type == typeof(bool)) + return PostSettingKind.Boolean; + if (type.IsEnum) + return PostSettingKind.Choice; + if ( + type == typeof(Dictionary) + || type == typeof(IDictionary) + ) + return PostSettingKind.StringMap; + + return null; + } + } +} diff --git a/OpenNest.Tests/PostSettings/PostSettingsLayoutTests.cs b/OpenNest.Tests/PostSettings/PostSettingsLayoutTests.cs new file mode 100644 index 0000000..670287d --- /dev/null +++ b/OpenNest.Tests/PostSettings/PostSettingsLayoutTests.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using OpenNest.Posts.CincinnatiCIFiber; +using OpenNest.PostSettings; +using Xunit; + +namespace OpenNest.Tests.PostSettings; + +public class PostSettingsLayoutTests +{ + [Fact] + public void CIFiberConfig_BuildsOrderedSections_CoveringEveryEditableProperty() + { + var sections = PostSettingsLayout.TryBuild(typeof(CIFiberPostConfig)); + + Assert.NotNull(sections); + Assert.Equal( + new[] { "Machine", "Material", "Program output", "Macros" }, + sections.Select(s => s.Name) + ); + Assert.All(sections, s => Assert.NotEmpty(s.Description)); + + var shown = sections.SelectMany(s => s.Fields).Select(f => f.Name).ToList(); + var editable = typeof(CIFiberPostConfig) + .GetProperties() + .Where(p => p.GetSetMethod() != null) + .Select(p => p.Name) + .ToList(); + Assert.Equal(editable.OrderBy(n => n), shown.OrderBy(n => n)); + Assert.Equal(shown.Count, shown.Distinct().Count()); + + var machine = sections[0].Fields; + Assert.Equal( + new[] { "ConfigurationName", "MaxTableX", "MaxTableY" }, + machine.Select(f => f.Name) + ); + Assert.Equal(PostSettingKind.Decimal, machine[1].Kind); + Assert.Equal("Maximum table X", machine[1].Label); + Assert.Contains("0 disables", machine[1].Description); + + var materials = sections[1].Fields[0]; + Assert.Equal(PostSettingKind.StringMap, materials.Kind); + Assert.Equal("Material name", materials.KeyHeader); + Assert.Equal("Machine code", materials.ValueHeader); + + Assert.All( + sections.SelectMany(s => s.Fields), + f => Assert.False(string.IsNullOrWhiteSpace(f.Description), f.Name) + ); + } + + [Fact] + public void FieldsRoundTripValuesThroughTheConfig() + { + var config = new CIFiberPostConfig(); + var fields = PostSettingsLayout + .TryBuild(typeof(CIFiberPostConfig)) + .SelectMany(s => s.Fields) + .ToDictionary(f => f.Name); + + fields["MaxTableX"].SetValue(config, 120.5); + fields["PostedAccuracy"].SetValue(config, 4); + fields["SkipScribe"].SetValue(config, false); + + Assert.Equal(120.5, config.MaxTableX); + Assert.Equal(4, config.PostedAccuracy); + Assert.False(config.SkipScribe); + Assert.Equal(0, fields["PostedAccuracy"].DecimalPlaces); + Assert.Equal(6, fields["PostedAccuracy"].Maximum); + } + + [Fact] + public void UnannotatedConfig_FallsBackToPropertyGrid() + { + Assert.Null(PostSettingsLayout.TryBuild(typeof(PlainConfig))); + } + + [Fact] + public void UnsupportedPropertyType_FallsBackToPropertyGrid() + { + Assert.Null(PostSettingsLayout.TryBuild(typeof(NestedConfig))); + } + + [Fact] + public void UnmarkedEditableProperty_LandsInOtherSection() + { + var sections = PostSettingsLayout.TryBuild(typeof(PartlyMarkedConfig)); + + Assert.Equal(new[] { "Main", PostSettingsLayout.OtherSection }, sections.Select(s => s.Name)); + Assert.Equal("Extra", Assert.Single(sections[1].Fields).Name); + Assert.Equal(PostSettingKind.Choice, sections[1].Fields[0].Kind); + } + + [Fact] + public void BuildMap_TrimsSkipsBlankRowsAndKeepsComparer() + { + var map = PostSettingsLayout.BuildMap( + new[] + { + Row(" Mild Steel ", " MSN "), + Row("", ""), + Row("Stainless", "SS"), + }, + StringComparer.OrdinalIgnoreCase + ); + + Assert.Equal(2, map.Count); + Assert.Equal("MSN", map["mild steel"]); + Assert.Equal("SS", map["Stainless"]); + } + + [Fact] + public void BuildMap_RejectsDuplicateAndMissingNames() + { + var duplicate = Assert.Throws(() => + PostSettingsLayout.BuildMap( + new[] { Row("Mild Steel", "MSN"), Row("mild steel", "X") }, + StringComparer.OrdinalIgnoreCase + ) + ); + Assert.Contains("Row 2", duplicate.Message); + + var missing = Assert.Throws(() => + PostSettingsLayout.BuildMap(new[] { Row(" ", "SS") }, StringComparer.Ordinal) + ); + Assert.Contains("Row 1", missing.Message); + } + + private static KeyValuePair Row(string key, string value) => new(key, value); + + private sealed class PlainConfig + { + [DisplayName("Name")] + public string Name { get; set; } = ""; + } + + private sealed class NestedConfig + { + [PostSetting("Main")] + public string Name { get; set; } = ""; + + public PlainConfig Child { get; set; } = new(); + } + + private enum Mode + { + A, + B, + } + + private sealed class PartlyMarkedConfig + { + public Mode Extra { get; set; } + + [PostSetting("Main")] + public string Name { get; set; } = ""; + } +} diff --git a/OpenNest/Controls/PostSettingsEditor.cs b/OpenNest/Controls/PostSettingsEditor.cs new file mode 100644 index 0000000..47b6f2e --- /dev/null +++ b/OpenNest/Controls/PostSettingsEditor.cs @@ -0,0 +1,410 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Globalization; +using System.Linq; +using System.Windows.Forms; +using OpenNest.PostSettings; + +namespace OpenNest.Controls +{ + /// + /// Sectioned settings editor for a post-processor config described by + /// . Edits are held in the controls and + /// written to the config only by , so cancelling + /// leaves the config untouched. + /// + public sealed class PostSettingsEditor : UserControl + { + private static readonly Color HelpColor = SystemColors.GrayText; + + private readonly object config; + private readonly IReadOnlyList sections; + private readonly ListBox sectionList; + private readonly Panel pageHost; + private readonly List pages = new(); + private readonly List