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:
@@ -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; } = "";
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user