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.
This commit is contained in:
aj
2026-09-28 18:21:07 -04:00
parent c33337cea2
commit 35aa64fe4e
7 changed files with 955 additions and 0 deletions
@@ -0,0 +1,56 @@
using System;
namespace OpenNest.PostSettings
{
/// <summary>
/// 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 <c>DisplayName</c> and <c>Description</c>.
/// </summary>
[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; }
/// <summary>Lower bound for numeric fields; NaN uses the kind's default.</summary>
public double Minimum { get; set; } = double.NaN;
/// <summary>Upper bound for numeric fields; NaN uses the kind's default.</summary>
public double Maximum { get; set; } = double.NaN;
/// <summary>Decimal places for decimal fields; negative uses the default.</summary>
public int DecimalPlaces { get; set; } = -1;
/// <summary>Column header for the key of a name/value table.</summary>
public string KeyHeader { get; set; }
/// <summary>Column header for the value of a name/value table.</summary>
public string ValueHeader { get; set; }
}
/// <summary>Declares a settings section's order and description.</summary>
[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; }
}
}
@@ -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<DisplayNameAttribute>()?.DisplayName
?? property.Name;
Description = property.GetCustomAttribute<DescriptionAttribute>()?.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<string>();
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<PostSettingsField> fields
)
{
Name = name;
Description = description ?? "";
Fields = fields;
}
public string Name { get; }
public string Description { get; }
public IReadOnlyList<PostSettingsField> Fields { get; }
}
/// <summary>
/// Builds the sectioned settings layout for a post-processor config from its
/// <see cref="PostSettingAttribute"/> metadata, without any UI dependency.
/// </summary>
public static class PostSettingsLayout
{
public const string OtherSection = "Other";
/// <summary>
/// Returns the ordered sections, or null when the config should use the
/// generic PropertyGrid: it has no <see cref="PostSettingAttribute"/>,
/// or one of its editable properties has a type the editor cannot show.
/// Editable properties without the attribute go to the
/// <see cref="OtherSection"/> so none become uneditable.
/// </summary>
public static IReadOnlyList<PostSettingsSection> 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<BrowsableAttribute>()?.Browsable != false
)
.OrderBy(p => p.MetadataToken)
.ToList();
if (!properties.Any(p => p.GetCustomAttribute<PostSettingAttribute>() != 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<PostSettingAttribute>();
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<PostSettingsSectionAttribute>()
.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();
}
/// <summary>
/// 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 <see cref="FormatException"/> naming the 1-based row for a
/// missing or duplicate name.
/// </summary>
public static Dictionary<string, string> BuildMap(
IEnumerable<KeyValuePair<string, string>> rows,
IEqualityComparer<string> comparer
)
{
var map = new Dictionary<string, string>(comparer ?? StringComparer.Ordinal);
var row = 0;
foreach (var entry in rows ?? Enumerable.Empty<KeyValuePair<string, string>>())
{
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<string, string>)
|| type == typeof(IDictionary<string, string>)
)
return PostSettingKind.StringMap;
return null;
}
}
}
@@ -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<FormatException>(() =>
PostSettingsLayout.BuildMap(
new[] { Row("Mild Steel", "MSN"), Row("mild steel", "X") },
StringComparer.OrdinalIgnoreCase
)
);
Assert.Contains("Row 2", duplicate.Message);
var missing = Assert.Throws<FormatException>(() =>
PostSettingsLayout.BuildMap(new[] { Row(" ", "SS") }, StringComparer.Ordinal)
);
Assert.Contains("Row 1", missing.Message);
}
private static KeyValuePair<string, string> 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; } = "";
}
}
+410
View File
@@ -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
{
/// <summary>
/// Sectioned settings editor for a post-processor config described by
/// <see cref="PostSettingsLayout"/>. Edits are held in the controls and
/// written to the config only by <see cref="TryApply"/>, so cancelling
/// leaves the config untouched.
/// </summary>
public sealed class PostSettingsEditor : UserControl
{
private static readonly Color HelpColor = SystemColors.GrayText;
private readonly object config;
private readonly IReadOnlyList<PostSettingsSection> sections;
private readonly ListBox sectionList;
private readonly Panel pageHost;
private readonly List<Panel> pages = new();
private readonly List<Label> wrapLabels = new();
private readonly List<(PostSettingsField Field, int Section, Func<object> Read)> readers =
new();
public PostSettingsEditor(object config, IReadOnlyList<PostSettingsSection> sections)
{
this.config = config ?? throw new ArgumentNullException(nameof(config));
this.sections = sections ?? throw new ArgumentNullException(nameof(sections));
sectionList = new ListBox
{
Dock = DockStyle.Left,
Width = 170,
BorderStyle = BorderStyle.None,
BackColor = SystemColors.Control,
DrawMode = DrawMode.OwnerDrawFixed,
ItemHeight = 34,
IntegralHeight = false,
TabIndex = 0,
};
sectionList.DrawItem += SectionList_DrawItem;
sectionList.SelectedIndexChanged += (_, _) => ShowSection(sectionList.SelectedIndex);
var divider = new VerticalLine { Dock = DockStyle.Left, Width = 2 };
pageHost = new Panel { Dock = DockStyle.Fill, TabIndex = 1 };
for (var i = 0; i < sections.Count; i++)
{
sectionList.Items.Add(sections[i].Name);
var page = BuildPage(sections[i], i);
page.Visible = false;
pages.Add(page);
pageHost.Controls.Add(page);
}
Controls.Add(pageHost);
Controls.Add(divider);
Controls.Add(sectionList);
if (sections.Count > 0)
sectionList.SelectedIndex = 0;
}
/// <summary>
/// Validates every field and, only if all are valid, writes them to the
/// config. On failure the config is unchanged, the offending section is
/// shown, and <paramref name="error"/> describes the problem.
/// </summary>
public bool TryApply(out string error)
{
var values = new List<(PostSettingsField Field, object Value)>();
foreach (var (field, section, read) in readers)
{
try
{
values.Add((field, read()));
}
catch (FormatException ex)
{
sectionList.SelectedIndex = section;
error = $"{field.Label}: {ex.Message}";
return false;
}
}
foreach (var (field, value) in values)
field.SetValue(config, value);
error = null;
return true;
}
private void ShowSection(int index)
{
for (var i = 0; i < pages.Count; i++)
pages[i].Visible = i == index;
}
private Panel BuildPage(PostSettingsSection section, int sectionIndex)
{
var page = new Panel
{
Dock = DockStyle.Fill,
AutoScroll = true,
Padding = new Padding(16, 12, 16, 12),
};
var grid = new TableLayoutPanel
{
Dock = DockStyle.Top,
AutoSize = true,
AutoSizeMode = AutoSizeMode.GrowAndShrink,
ColumnCount = 2,
};
grid.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 190));
grid.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
var title = new Label
{
Text = section.Name,
AutoSize = true,
Font = new Font(Font.FontFamily, 13f, FontStyle.Bold),
Margin = new Padding(0, 0, 0, 4),
};
AddSpanning(grid, title);
if (section.Description.Length > 0)
{
var description = WrapLabel(section.Description, SystemColors.ControlText);
description.Margin = new Padding(0, 0, 0, 10);
AddSpanning(grid, description);
}
AddSpanning(grid, new HorizontalLine { Height = 8, Dock = DockStyle.Fill });
foreach (var field in section.Fields)
AddField(grid, field, sectionIndex);
page.Controls.Add(grid);
page.Resize += (_, _) => UpdateWrapWidths(page);
return page;
}
private void AddField(TableLayoutPanel grid, PostSettingsField field, int sectionIndex)
{
var value = field.GetValue(config);
Control input;
Func<object> read;
switch (field.Kind)
{
case PostSettingKind.Boolean:
{
var check = new CheckBox
{
Text = field.Label,
Checked = value is true,
AutoSize = true,
};
input = check;
read = () => check.Checked;
break;
}
case PostSettingKind.Integer:
case PostSettingKind.Decimal:
{
var number = new System.Windows.Forms.NumericUpDown
{
Minimum = (decimal)field.Minimum,
Maximum = (decimal)field.Maximum,
DecimalPlaces = field.DecimalPlaces,
Increment =
field.Kind == PostSettingKind.Integer ? 1m
: field.DecimalPlaces >= 2 ? 0.25m
: 1m,
Width = 120,
TextAlign = HorizontalAlignment.Right,
ThousandsSeparator = false,
};
number.Value = Clamp(number, Convert.ToDecimal(value, CultureInfo.InvariantCulture));
input = number;
read = field.Kind == PostSettingKind.Integer
? () => (object)(int)number.Value
: () => (object)(double)number.Value;
break;
}
case PostSettingKind.Choice:
{
var combo = new ComboBox { DropDownStyle = ComboBoxStyle.DropDownList, Width = 200 };
combo.Items.AddRange(field.ChoiceNames);
combo.SelectedItem = value?.ToString();
input = combo;
read = () =>
Enum.Parse(field.Property.PropertyType, (string)combo.SelectedItem ?? field.ChoiceNames[0]);
break;
}
case PostSettingKind.StringMap:
AddMapField(grid, field, sectionIndex, value as IDictionary<string, string>);
return;
default:
{
var text = new TextBox { Text = value as string ?? "", Width = 280 };
input = text;
read = () => text.Text;
break;
}
}
input.Margin = new Padding(3, 10, 3, 0);
input.Anchor = AnchorStyles.Left;
var row = grid.RowCount++;
grid.RowStyles.Add(new RowStyle(SizeType.AutoSize));
if (field.Kind != PostSettingKind.Boolean)
{
var label = new Label
{
Text = field.Label,
AutoSize = true,
Anchor = AnchorStyles.Left,
Margin = new Padding(0, 10, 8, 0),
};
grid.Controls.Add(label, 0, row);
}
grid.Controls.Add(input, 1, row);
AddHelp(grid, field.Description);
readers.Add((field, sectionIndex, read));
}
private void AddMapField(
TableLayoutPanel grid,
PostSettingsField field,
int sectionIndex,
IDictionary<string, string> map
)
{
var label = new Label
{
Text = field.Label,
AutoSize = true,
Font = new Font(Font, FontStyle.Bold),
Margin = new Padding(0, 12, 0, 2),
};
AddSpanning(grid, label);
AddHelp(grid, field.Description, spanning: true);
var table = new DataGridView
{
Dock = DockStyle.Fill,
Height = 200,
AllowUserToAddRows = true,
AllowUserToDeleteRows = true,
AllowUserToResizeRows = false,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
BackgroundColor = SystemColors.Window,
BorderStyle = BorderStyle.FixedSingle,
ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize,
RowHeadersWidth = 28,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
MultiSelect = true,
Margin = new Padding(0, 4, 0, 0),
};
table.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = field.KeyHeader, FillWeight = 65 });
table.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = field.ValueHeader, FillWeight = 35 });
if (map != null)
{
foreach (var entry in map.OrderBy(e => e.Key, StringComparer.OrdinalIgnoreCase))
table.Rows.Add(entry.Key, entry.Value);
}
AddSpanning(grid, table);
var removeButton = new Button
{
Text = "Remove selected",
AutoSize = true,
Margin = new Padding(0, 4, 0, 0),
};
removeButton.Click += (_, _) =>
{
foreach (DataGridViewRow selected in table.SelectedRows)
{
if (!selected.IsNewRow)
table.Rows.Remove(selected);
}
};
AddSpanning(grid, removeButton);
AddHelp(grid, "Type in the empty last row to add an entry.", spanning: true);
var comparer = (map as Dictionary<string, string>)?.Comparer ?? StringComparer.Ordinal;
readers.Add(
(
field,
sectionIndex,
() =>
{
table.EndEdit();
var rows = table
.Rows.Cast<DataGridViewRow>()
.Where(r => !r.IsNewRow)
.Select(r => new KeyValuePair<string, string>(
r.Cells[0].Value as string,
r.Cells[1].Value as string
));
return PostSettingsLayout.BuildMap(rows, comparer);
}
)
);
}
private void AddHelp(TableLayoutPanel grid, string text, bool spanning = false)
{
if (string.IsNullOrWhiteSpace(text))
return;
var help = WrapLabel(text, HelpColor);
help.Margin = new Padding(3, 2, 3, 4);
if (spanning)
{
AddSpanning(grid, help);
return;
}
var row = grid.RowCount++;
grid.RowStyles.Add(new RowStyle(SizeType.AutoSize));
grid.Controls.Add(help, 1, row);
}
private static void AddSpanning(TableLayoutPanel grid, Control control)
{
var row = grid.RowCount++;
grid.RowStyles.Add(new RowStyle(SizeType.AutoSize));
grid.Controls.Add(control, 0, row);
grid.SetColumnSpan(control, 2);
}
private Label WrapLabel(string text, Color color)
{
var label = new Label
{
Text = text,
AutoSize = true,
ForeColor = color,
};
wrapLabels.Add(label);
return label;
}
private void UpdateWrapWidths(Panel page)
{
var width = System.Math.Max(200, page.ClientSize.Width - page.Padding.Horizontal - 24);
foreach (var label in wrapLabels)
{
if (IsOnPage(label, page))
label.MaximumSize = new System.Drawing.Size(width, 0);
}
}
private static bool IsOnPage(Control control, Control page)
{
for (var parent = control.Parent; parent != null; parent = parent.Parent)
{
if (parent == page)
return true;
}
return false;
}
private void SectionList_DrawItem(object sender, DrawItemEventArgs e)
{
if (e.Index < 0)
return;
var selected = (e.State & DrawItemState.Selected) != 0;
var back = selected ? SystemColors.Highlight : sectionList.BackColor;
var fore = selected ? SystemColors.HighlightText : SystemColors.ControlText;
using (var brush = new SolidBrush(back))
e.Graphics.FillRectangle(brush, e.Bounds);
var textBounds = new Rectangle(
e.Bounds.X + 12,
e.Bounds.Y,
e.Bounds.Width - 12,
e.Bounds.Height
);
TextRenderer.DrawText(
e.Graphics,
sectionList.Items[e.Index].ToString(),
sectionList.Font,
textBounds,
fore,
TextFormatFlags.VerticalCenter | TextFormatFlags.Left | TextFormatFlags.EndEllipsis
);
}
private static decimal Clamp(System.Windows.Forms.NumericUpDown box, decimal value) =>
System.Math.Min(box.Maximum, System.Math.Max(box.Minimum, value));
}
}
+33
View File
@@ -1,6 +1,8 @@
using System;
using System.Text.Json;
using System.Windows.Forms;
using OpenNest.Controls;
using OpenNest.PostSettings;
namespace OpenNest.Forms
{
@@ -8,6 +10,7 @@ namespace OpenNest.Forms
{
private readonly IConfigurablePostProcessor postProcessor;
private readonly string configBackup;
private readonly PostSettingsEditor settingsEditor;
public PostProcessorConfigForm(IConfigurablePostProcessor postProcessor)
{
@@ -16,6 +19,25 @@ namespace OpenNest.Forms
this.postProcessor = postProcessor;
this.Text = postProcessor.Name + " Settings";
var sections = PostSettingsLayout.TryBuild(postProcessor.Config.GetType());
if (sections != null)
{
// Sectioned editor: edits stay in the controls until OK applies them,
// so Cancel needs no backup.
settingsEditor = new PostSettingsEditor(postProcessor.Config, sections)
{
Dock = DockStyle.Fill,
};
Controls.Remove(propertyGrid);
propertyGrid.Dispose();
Controls.Add(settingsEditor);
settingsEditor.BringToFront();
ClientSize = new System.Drawing.Size(760, 560);
MinimumSize = new System.Drawing.Size(620, 420);
MaximizeBox = true;
return;
}
// Deep-clone config as JSON backup for cancel/restore
configBackup = JsonSerializer.Serialize(
postProcessor.Config,
@@ -27,11 +49,22 @@ namespace OpenNest.Forms
private void okButton_Click(object sender, EventArgs e)
{
if (settingsEditor != null && !settingsEditor.TryApply(out var error))
{
DialogResult = DialogResult.None;
MessageBox.Show(this, error, Text, MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
postProcessor.SaveConfig();
}
private void cancelButton_Click(object sender, EventArgs e)
{
// The sectioned editor never wrote to the config.
if (configBackup == null)
return;
// Restore config from backup
var original = JsonSerializer.Deserialize(configBackup, postProcessor.Config.GetType());
var properties = postProcessor.Config.GetType().GetProperties();
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using OpenNest.PostSettings;
namespace OpenNest.Posts.CincinnatiCIFiber
{
@@ -9,10 +10,37 @@ namespace OpenNest.Posts.CincinnatiCIFiber
/// All layer macro names, header variable names and the material code map are
/// editable so the post can serve other CI Fiber tables, not only the 4020.
/// </summary>
[PostSettingsSection(
MachineSection,
0,
Description = "Which CI Fiber table this configuration drives and the largest sheet it accepts."
)]
[PostSettingsSection(
MaterialSection,
1,
Description = "Maps OpenNest material names to the controller's V.E.MATERIAL code."
)]
[PostSettingsSection(
OutputSection,
2,
Description = "Number format, units, and what the posted program includes."
)]
[PostSettingsSection(
MacrosSection,
3,
Description = "Controller subroutines called around each contour and at program start and end. They must exist on the machine."
)]
public class CIFiberPostConfig
{
private const string MachineSection = "Machine";
private const string MaterialSection = "Material";
private const string OutputSection = "Program output";
private const string MacrosSection = "Macros";
/// <summary>Emitted in the "( CONFIGURATION - ... )" header comment.</summary>
[DisplayName("Configuration name")]
[Description("Shown in the program header comment ( CONFIGURATION - ... ).")]
[PostSetting(MachineSection, 0)]
public string ConfigurationName { get; set; } = "CI FIBER 8K";
/// <summary>
@@ -20,10 +48,14 @@ namespace OpenNest.Posts.CincinnatiCIFiber
/// machine program posts 3 decimals for inch units.
/// </summary>
[DisplayName("Posted accuracy")]
[Description("Decimal places for coordinates and header values. The machine sample uses 3 for inches.")]
[PostSetting(OutputSection, 0, Minimum = 0, Maximum = 6)]
public int PostedAccuracy { get; set; } = 3;
/// <summary>Value for V.E.UNIT when the nest is in inches.</summary>
[DisplayName("Unit code (inches)")]
[Description("V.E.UNIT value written for inch nests.")]
[PostSetting(OutputSection, 1, Minimum = 0, Maximum = 99)]
public int InchUnitCode { get; set; } = 1;
/// <summary>
@@ -31,63 +63,93 @@ namespace OpenNest.Posts.CincinnatiCIFiber
/// metric unit code is unconfirmed; adjust if the controller rejects 0.
/// </summary>
[DisplayName("Unit code (millimeters)")]
[Description("V.E.UNIT value written for millimeter nests. Unconfirmed on the controller.")]
[PostSetting(OutputSection, 2, Minimum = 0, Maximum = 99)]
public int MetricUnitCode { get; set; } = 0;
/// <summary>Emit the V.E.SHEET_WEIGHT header variable (value 0).</summary>
[DisplayName("Emit sheet weight")]
[Description("Write the V.E.SHEET_WEIGHT header variable (value 0).")]
[PostSetting(OutputSection, 3)]
public bool EmitSheetWeight { get; set; } = false;
/// <summary>Skip contours whose moves are all LayerType.Scribe (marks).</summary>
[DisplayName("Skip scribe layer")]
[Description("Leave out contours that are entirely scribe/etch marks.")]
[PostSetting(OutputSection, 4)]
public bool SkipScribe { get; set; } = true;
/// <summary>Maximum plate length (X) this configuration drives; 0 disables the check.</summary>
[DisplayName("Maximum table X")]
[Description("Largest plate length (X) this table accepts. Posting a larger plate fails. 0 disables the check.")]
[PostSetting(MachineSection, 1, Minimum = 0, Maximum = 10000, DecimalPlaces = 3)]
public double MaxTableX { get; set; } = 160.25;
/// <summary>Maximum plate width (Y) this configuration drives; 0 disables the check.</summary>
[DisplayName("Maximum table Y")]
[Description("Largest plate width (Y) this table accepts. Posting a larger plate fails. 0 disables the check.")]
[PostSetting(MachineSection, 2, Minimum = 0, Maximum = 10000, DecimalPlaces = 3)]
public double MaxTableY { get; set; } = 81.25;
/// <summary>Comment text after the "( PART:" prefix for each part.</summary>
[DisplayName("Part comment")]
[Description("Text after \"( PART:\" for each part. Blank uses the source file or drawing name.")]
[PostSetting(OutputSection, 5)]
public string PartComment { get; set; } = "";
/// <summary>Skippable global subroutine used to cancel comp / park between features.</summary>
[DisplayName("Layer: cancel (L0)")]
[Description("Called before each contour and at the end to cancel compensation and park.")]
[PostSetting(MacrosSection, 0)]
public string LayerCancel { get; set; } = "L0";
/// <summary>Skippable global subroutine for interior lead-in plus G41.</summary>
[DisplayName("Layer: interior leadin (L2)")]
[Description("Called before an interior (hole) lead-in, with G41.")]
[PostSetting(MacrosSection, 1)]
public string LayerInteriorLeadin { get; set; } = "L2";
/// <summary>Skippable global subroutine for exterior lead-in plus G42.</summary>
[DisplayName("Layer: exterior leadin (L4)")]
[Description("Called before an exterior (perimeter) lead-in, with G42.")]
[PostSetting(MacrosSection, 2)]
public string LayerExteriorLeadin { get; set; } = "L4";
/// <summary>Skippable global subroutine that switches the cut layer on.</summary>
[DisplayName("Layer: cut on (L6)")]
[Description("Switches the cut layer on after the lead-in.")]
[PostSetting(MacrosSection, 3)]
public string LayerCut { get; set; } = "L6";
/// <summary>Skippable global subroutine at the end of each contour (head separation / comp off).</summary>
[DisplayName("Layer: cut end (ZHSOFF)")]
[Description("Called at the end of each contour (head separation).")]
[PostSetting(MacrosSection, 4)]
public string LayerCutEnd { get; set; } = "ZHSOFF";
/// <summary>External program called once at program start.</summary>
[DisplayName("Start macro")]
[Description("Program called once at the start (L name).")]
[PostSetting(MacrosSection, 5)]
public string ProgramStartMacro { get; set; } = "PROGRAMSTART.NC";
/// <summary>External program called after the last sheet.</summary>
[DisplayName("End macro")]
[Description("Program called after the last sheet (L name).")]
[PostSetting(MacrosSection, 6)]
public string ProgramEndMacro { get; set; } = "PROGRAMEND.NC";
/// <summary>Material name (case-insensitive) to machine material code map for V.E.MATERIAL.</summary>
[DisplayName("Material codes")]
[Description("Material name (not case-sensitive) and the code written to V.E.MATERIAL.")]
[PostSetting(MaterialSection, 0, KeyHeader = "Material name", ValueHeader = "Machine code")]
public Dictionary<string, string> MaterialCodes { get; set; } =
new(StringComparer.OrdinalIgnoreCase) { ["Mild Steel"] = "MSN" };
/// <summary>Fallback V.E.MATERIAL code when the material name has no mapping.</summary>
[DisplayName("Default material code")]
[Description("Code used when the nest material has no entry in the table above.")]
[PostSetting(MaterialSection, 1)]
public string DefaultMaterialCode { get; set; } = "MSN";
public string ResolveMaterialCode(string materialName)
+3
View File
@@ -42,6 +42,9 @@ uses the Cincinnati machine-sample convention, not a generic TF5200 laser API:
See [CIFiberPostConfig](../Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostConfig.cs)
for macro names, material mappings, unit codes, precision, and table limits.
The post is named for the machine family; table size belongs in configuration.
In the desktop app these settings open in a sectioned editor (Machine, Material,
Program output, Macros) with a material-code table; values are validated and
saved only when OK is pressed.
- Assign linear lead-ins before posting, including circular holes. The writer
rejects missing or arc-first lead-ins. Its conservative rule cites the G238