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