feat(desktop): pick sheet gauges and fractions from thickness dropdowns

Operators had to know that 16 ga is 0.0598 to enter a thickness. The Nest
Info and Save to Drawing Library thickness boxes now list steel gauges
(26-3 ga) and plate fractions (1/16"-2") with their decimals, and read a
typed gauge ("16ga", "#16"), any fraction ("3/32", "1-1/4") or a decimal.
Unreadable text blocks OK/Save instead of reading as zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-22 13:04:55 -04:00
1 parent 9d69c89b21
commit d4903a648c
9 files changed
+334 -35

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace OpenNest.Data;
/// <summary>
/// The sheet gauges (manufacturers' standard gauge for steel) and common plate
/// fractions offered in thickness boxes, so an operator can pick "16 ga - 0.0598"
/// without knowing the decimal. Typed text still accepts any decimal thickness.
/// </summary>
public static partial class StandardThicknesses
{
/// <summary>One listed thickness: its name ("16 ga", "1/4\"") and value in inches.</summary>
public sealed record Entry(string Name, double Inches);
private const double MillimetersPerInch = 25.4;
private static readonly (int Gauge, double Inches)[] SteelGauges =
{
(3, 0.2391), (4, 0.2242), (5, 0.2092), (6, 0.1943), (7, 0.1793), (8, 0.1644),
(9, 0.1495), (10, 0.1345), (11, 0.1196), (12, 0.1046), (13, 0.0897), (14, 0.0747),
(15, 0.0673), (16, 0.0598), (17, 0.0538), (18, 0.0478), (19, 0.0418), (20, 0.0359),
(21, 0.0329), (22, 0.0299), (23, 0.0269), (24, 0.0239), (25, 0.0209), (26, 0.0179),
};
private static readonly string[] PlateFractions =
{
"1/16", "1/8", "3/16", "1/4", "5/16", "3/8", "7/16", "1/2", "9/16", "5/8", "11/16",
"3/4", "7/8", "1", "1-1/8", "1-1/4", "1-3/8", "1-1/2", "1-3/4", "2",
};
/// <summary>Gauges thinnest first, then plate fractions thinnest first.</summary>
public static IReadOnlyList<Entry> Entries { get; } = SteelGauges
.OrderBy(g => g.Inches)
.Select(g => new Entry($"{g.Gauge} ga", g.Inches))
.Concat(PlateFractions.Select(f => new Entry($"{f}\"", ReadInches(f) ?? 0)))
.ToArray();
/// <summary>The value of <paramref name="entry"/> in <paramref name="units"/>.</summary>
public static double ValueIn(Entry entry, Units units) =>
units == Units.Millimeters ? entry.Inches * MillimetersPerInch : entry.Inches;
/// <summary>The list text for <paramref name="entry"/>, e.g. "16 ga - 0.0598".</summary>
public static string Label(Entry entry, Units units) =>
$"{entry.Name} - {FormatNumber(ValueIn(entry, units), units)}";
/// <summary>
/// The text a thickness box shows for <paramref name="value"/>: the listed label when
/// it matches an entry to the displayed precision, otherwise the plain number.
/// </summary>
public static string Format(double value, Units units)
{
var entry = Find(value, units);
return entry != null ? Label(entry, units) : FormatNumber(value, units);
}
/// <summary>The entry <paramref name="value"/> matches to the displayed precision, or null.</summary>
public static Entry? Find(double value, Units units)
{
var tolerance = units == Units.Millimeters ? 0.0005 : 0.00005;
return Entries.FirstOrDefault(e => System.Math.Abs(ValueIn(e, units) - value) < tolerance);
}
/// <summary>
/// Reads a thickness box: a list label, a gauge ("16 ga", "16ga", "#16"), a fraction of
/// an inch ("3/32", "1-1/4", "1 1/4\"") or a non-negative number. Blank text reads as 0.
/// </summary>
public static bool TryParse(string? text, Units units, out double value)
{
value = 0;
var trimmed = (text ?? "").Trim();
if (trimmed.Length == 0)
return true;
var entry = Entries.FirstOrDefault(e =>
string.Equals(Label(e, units), trimmed, StringComparison.OrdinalIgnoreCase));
var gauge = GaugePattern().Match(trimmed);
if (entry == null && gauge.Success)
entry = Entries.FirstOrDefault(e => e.Name == $"{gauge.Groups["g"].Value} ga");
if (entry != null)
{
value = ValueIn(entry, units);
return true;
}
if (gauge.Success)
return false;
// Fractions are always inches, converted for a millimeter box.
if (trimmed.Contains('/'))
{
if (ReadInches(trimmed) is not double inches)
return false;
value = units == Units.Millimeters ? inches * MillimetersPerInch : inches;
return true;
}
return double.TryParse(trimmed, NumberStyles.Float, CultureInfo.CurrentCulture, out value)
&& double.IsFinite(value) && value >= 0;
}
private static string FormatNumber(double value, Units units) =>
value.ToString(units == Units.Millimeters ? "0.###" : "0.####", CultureInfo.CurrentCulture);
/// <summary>Reads "3/16", "1-1/4" or "1 1/4\"" as inches; null when not a fraction.</summary>
private static double? ReadInches(string text)
{
var match = FractionPattern().Match(text.Trim());
if (!match.Success)
return null;
var whole = match.Groups["w"].Success ? int.Parse(match.Groups["w"].Value, CultureInfo.InvariantCulture) : 0;
if (!match.Groups["n"].Success)
return whole;
var numerator = int.Parse(match.Groups["n"].Value, CultureInfo.InvariantCulture);
var denominator = int.Parse(match.Groups["d"].Value, CultureInfo.InvariantCulture);
return denominator == 0 ? null : whole + (double)numerator / denominator;
}
[GeneratedRegex(@"^(?:(?<w>\d{1,3})(?:[\s-]+(?<n>\d{1,4})/(?<d>\d{1,4}))?|(?<n>\d{1,4})/(?<d>\d{1,4}))\s*""?$")]
private static partial Regex FractionPattern();
[GeneratedRegex(@"^(?:#\s*(?<g>\d+)|(?<g>\d+)\s*(?:ga|gauge|gage)\.?)$", RegexOptions.IgnoreCase)]
private static partial Regex GaugePattern();
}
@@ -0,0 +1,72 @@
using OpenNest.Data;
namespace OpenNest.Tests.Data;
/// <summary>
/// The thickness box list: gauges and plate fractions read and display as their
/// decimals, and any other typed decimal still works.
/// </summary>
public class StandardThicknessesTests
{
[Theory]
[InlineData("16 ga - 0.0598", 0.0598)]
[InlineData("16 ga", 0.0598)]
[InlineData("16GA", 0.0598)]
[InlineData("16 gauge", 0.0598)]
[InlineData("#10", 0.1345)]
[InlineData("1/4", 0.25)]
[InlineData("1/4\"", 0.25)]
[InlineData("3/32", 0.09375)]
[InlineData("1-1/4", 1.25)]
[InlineData("1 1/4\"", 1.25)]
[InlineData("1-1/2\" - 1.5", 1.5)]
[InlineData("0.0598", 0.0598)]
[InlineData("0.123", 0.123)]
[InlineData("", 0)]
public void TryParse_ReadsGaugesFractionsAndDecimals(string text, double expected)
{
Assert.True(StandardThicknesses.TryParse(text, Units.Inches, out var value));
Assert.Equal(expected, value, 6);
}
[Theory]
[InlineData("abc")]
[InlineData("99 ga")]
[InlineData("-0.1")]
[InlineData("1/0")]
[InlineData("1/")]
public void TryParse_RejectsUnknownText(string text) =>
Assert.False(StandardThicknesses.TryParse(text, Units.Inches, out _));
[Fact]
public void Format_ShowsTheGaugeForAMatchingDecimal()
{
Assert.Equal("16 ga - 0.0598", StandardThicknesses.Format(0.0598, Units.Inches));
Assert.Equal("1/4\" - 0.25", StandardThicknesses.Format(0.25, Units.Inches));
Assert.Equal("1-1/4\" - 1.25", StandardThicknesses.Format(1.25, Units.Inches));
Assert.Equal("0.123", StandardThicknesses.Format(0.123, Units.Inches));
}
[Fact]
public void Millimeters_ListAndReadMetricValues()
{
Assert.Equal("16 ga - 1.519", StandardThicknesses.Format(0.0598 * 25.4, Units.Millimeters));
Assert.True(StandardThicknesses.TryParse("16 ga", Units.Millimeters, out var value));
Assert.Equal(1.51892, value, 5);
Assert.True(StandardThicknesses.TryParse("3/32", Units.Millimeters, out value));
Assert.Equal(2.38125, value, 5);
}
[Fact]
public void Entries_ListGaugesThenFractions_ThinnestFirst()
{
var gauges = StandardThicknesses.Entries.TakeWhile(e => e.Name.EndsWith(" ga")).ToList();
Assert.Equal("26 ga", gauges[0].Name);
Assert.Equal("3 ga", gauges[^1].Name);
Assert.Equal(gauges.OrderBy(e => e.Inches), gauges);
var fractions = StandardThicknesses.Entries.Skip(gauges.Count).ToList();
Assert.Equal(("1/16\"", 0.0625), (fractions[0].Name, fractions[0].Inches));
Assert.Equal(("2\"", 2.0), (fractions[^1].Name, fractions[^1].Inches));
Assert.Equal(fractions.OrderBy(e => e.Inches), fractions);
}
}
@@ -49,6 +49,31 @@ public class EditNestInfoFormTests
});
}
[Fact]
public void ThicknessBox_ReadsAPickedGauge_AndRejectsUnreadableText()
{
RunSta(() =>
{
using var form = new EditNestInfoForm();
var nest = AcceptanceNest();
form.LoadNestInfo(nest);
var thickness = (OpenNest.Controls.ThicknessComboBox)form.Controls.Find("thicknessBox", true).Single();
var apply = form.Controls.Find("applyButton", true).Single();
Assert.Equal("1/4\" - 0.25", thickness.Text);
thickness.SelectedItem = "16 ga - 0.0598";
Assert.Equal(0.0598, form.Thickness);
Assert.True(apply.Enabled);
thickness.Text = "sixteen";
Assert.False(apply.Enabled);
thickness.Text = "16ga";
form.SaveNestInfo(nest);
Assert.Equal(0.0598, nest.Thickness);
});
}
private static Nest AcceptanceNest()
{
var nest = new Nest("Acceptance nest")
@@ -30,7 +30,7 @@ public class SaveToDrawingLibraryFormTests
Assert.Equal("SYN-101", Field<TextBox>(form, "nameBox").Text);
Assert.Equal("Synthetic Steel", Field<ComboBox>(form, "materialBox").Text);
Assert.Equal("Grade A", Field<TextBox>(form, "gradeBox").Text);
Assert.Equal(0.25m, Field<NumericUpDown>(form, "thicknessBox").Value);
Assert.Equal("1/4\" - 0.25", Field<OpenNest.Controls.ThicknessComboBox>(form, "thicknessBox").Text);
Assert.Equal(CutType.Waterjet, Field<ComboBox>(form, "cutTypeBox").SelectedItem);
PumpUntil(() => save.Enabled, "the duplicate check");
Assert.Equal("Create a separate library drawing", saveAs.Text);
@@ -62,18 +62,18 @@ public class SaveToDrawingLibraryFormTests
form.Show();
var saveAs = Field<ComboBox>(form, "saveAsBox");
var save = Field<Button>(form, "saveButton");
var thickness = Field<NumericUpDown>(form, "thicknessBox");
var thickness = Field<OpenNest.Controls.ThicknessComboBox>(form, "thicknessBox");
PumpUntil(() => save.Enabled, "the duplicate check");
Assert.StartsWith("Revision 2 of 'SYN-101'", saveAs.Text);
thickness.Value = 0.1875m;
thickness.Value = 0.1875;
Assert.False(save.Enabled);
Assert.Equal(-1, saveAs.SelectedIndex);
PumpUntil(() => save.Enabled, "the second duplicate check");
Assert.Equal("Create a separate library drawing", saveAs.Text);
thickness.Value = 0.25m;
thickness.Value = 0.25;
PumpUntil(() => save.Enabled, "the third duplicate check");
Run(form.SaveAsync());
+81
View File
@@ -0,0 +1,81 @@
using System;
using System.ComponentModel;
using System.Windows.Forms;
using OpenNest.Data;
namespace OpenNest.Controls
{
/// <summary>
/// A thickness box that lists the standard gauges and plate fractions ("16 ga - 0.0598")
/// for the current units while still accepting any typed decimal. See
/// <see cref="StandardThicknesses"/> for the accepted text.
/// </summary>
public class ThicknessComboBox : ComboBox
{
private Units units = Units.Inches;
public ThicknessComboBox()
{
DropDownStyle = ComboBoxStyle.DropDown;
MaxDropDownItems = 16;
FillItems();
}
/// <summary>The units the listed values and typed numbers are in.</summary>
[DefaultValue(Units.Inches)]
public Units Units
{
get { return units; }
set
{
if (units == value)
return;
// The thickness keeps its number; only the listed values change units.
var valid = TryGetValue(out var current);
units = value;
FillItems();
if (valid)
Value = current;
}
}
/// <summary>True when the text reads as a thickness.</summary>
[Browsable(false)]
public bool IsValid => TryGetValue(out _);
/// <summary>The thickness in <see cref="Units"/>; 0 when blank or unreadable.</summary>
[Browsable(false)]
[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public double Value
{
get { return TryGetValue(out var value) ? value : 0; }
set { Text = StandardThicknesses.Format(value, units); }
}
public bool TryGetValue(out double value) => StandardThicknesses.TryParse(Text, units, out value);
protected override void OnEnter(EventArgs e)
{
base.OnEnter(e);
SelectAll();
}
protected override void OnLeave(EventArgs e)
{
// Show a typed gauge or matching decimal as its listed label.
if (TryGetValue(out var value) && Text.Trim().Length > 0)
Value = value;
base.OnLeave(e);
}
private void FillItems()
{
var text = Text;
Items.Clear();
foreach (var entry in StandardThicknesses.Entries)
Items.Add(StandardThicknesses.Label(entry, units));
Text = text;
}
}
}
+3 -16
View File
@@ -55,7 +55,7 @@
this.radioButton1 = new System.Windows.Forms.RadioButton();
this.radioButton2 = new System.Windows.Forms.RadioButton();
this.label2 = new System.Windows.Forms.Label();
this.thicknessBox = new OpenNest.Controls.NumericUpDown();
this.thicknessBox = new OpenNest.Controls.ThicknessComboBox();
this.label3 = new System.Windows.Forms.Label();
this.label4 = new System.Windows.Forms.Label();
this.customerBox = new System.Windows.Forms.TextBox();
@@ -90,7 +90,6 @@
this.tabPage1.SuspendLayout();
this.tableLayoutPanel3.SuspendLayout();
this.tableLayoutPanel4.SuspendLayout();
((System.ComponentModel.ISupportInitialize)(this.thicknessBox)).BeginInit();
this.tabPage2.SuspendLayout();
this.tabPage3.SuspendLayout();
this.bottomPanel1.SuspendLayout();
@@ -502,21 +501,10 @@
// thicknessBox
//
this.thicknessBox.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right)));
this.thicknessBox.DecimalPlaces = 4;
this.thicknessBox.Increment = new decimal(new int[] {
25,
0,
0,
131072});
this.thicknessBox.Location = new System.Drawing.Point(135, 125);
this.thicknessBox.Maximum = new decimal(new int[] {
999999,
0,
0,
0});
this.thicknessBox.Name = "thicknessBox";
this.thicknessBox.Size = new System.Drawing.Size(224, 22);
this.thicknessBox.Suffix = "";
this.thicknessBox.FormattingEnabled = true;
this.thicknessBox.TabIndex = 7;
//
// labelMaterial
@@ -753,7 +741,6 @@
this.tableLayoutPanel3.PerformLayout();
this.tableLayoutPanel4.ResumeLayout(false);
this.tableLayoutPanel4.PerformLayout();
((System.ComponentModel.ISupportInitialize)(this.thicknessBox)).EndInit();
this.tabPage2.ResumeLayout(false);
this.tabPage3.ResumeLayout(false);
this.tabPage3.PerformLayout();
@@ -779,7 +766,7 @@
private System.Windows.Forms.TableLayoutPanel tableLayoutPanel1;
private System.Windows.Forms.Label labelSize;
private System.Windows.Forms.TextBox sizeBox;
private Controls.NumericUpDown thicknessBox;
private Controls.ThicknessComboBox thicknessBox;
private System.Windows.Forms.Label labelThk;
private System.Windows.Forms.Label labelPartSpacing;
private Controls.NumericUpDown partSpacingBox;
+11 -3
View File
@@ -38,6 +38,7 @@ namespace OpenNest.Forms
};
Disposed += (sender, e) => timer.Dispose();
nameBox.TextChanged += (sender, e) => EnableCheck();
thicknessBox.TextChanged += (sender, e) => EnableCheck();
materialBox.TextChanged += (sender, e) => RefreshGrades();
EnableCheck();
}
@@ -137,8 +138,8 @@ namespace OpenNest.Forms
public double Thickness
{
get { return (double)thicknessBox.Value; }
set { thicknessBox.Value = (decimal)value; }
get { return thicknessBox.Value; }
set { thicknessBox.Value = value; }
}
public string MaterialName
@@ -241,7 +242,6 @@ namespace OpenNest.Forms
{
var unitBoxes = new Controls.NumericUpDown[]
{
thicknessBox,
partSpacingBox,
leftSpacingBox,
topSpacingBox,
@@ -253,6 +253,8 @@ namespace OpenNest.Forms
foreach (var box in unitBoxes)
box.Suffix = unitString;
thicknessBox.Units = GetUnits();
}
public int Quadrant
@@ -271,6 +273,12 @@ namespace OpenNest.Forms
return;
}
if (!thicknessBox.IsValid)
{
applyButton.Enabled = false;
return;
}
if (!OpenNest.Geometry.Size.TryParse(sizeBox.Text, out size))
{
applyButton.Enabled = false;
+4 -10
View File
@@ -28,7 +28,7 @@ namespace OpenNest.Forms
gradeLabel = new System.Windows.Forms.Label();
gradeBox = new System.Windows.Forms.TextBox();
thicknessLabel = new System.Windows.Forms.Label();
thicknessBox = new System.Windows.Forms.NumericUpDown();
thicknessBox = new OpenNest.Controls.ThicknessComboBox();
cutTypeLabel = new System.Windows.Forms.Label();
cutTypeBox = new System.Windows.Forms.ComboBox();
notesLabel = new System.Windows.Forms.Label();
@@ -44,7 +44,6 @@ namespace OpenNest.Forms
saveButton = new System.Windows.Forms.Button();
lookupTimer = new System.Windows.Forms.Timer(components);
layoutTable.SuspendLayout();
((System.ComponentModel.ISupportInitialize)thicknessBox).BeginInit();
buttonPanel.SuspendLayout();
SuspendLayout();
//
@@ -172,14 +171,10 @@ namespace OpenNest.Forms
// thicknessBox
//
thicknessBox.Anchor = System.Windows.Forms.AnchorStyles.Left;
thicknessBox.DecimalPlaces = 4;
thicknessBox.Increment = new decimal(new int[] { 1, 0, 0, 131072 });
thicknessBox.Maximum = new decimal(new int[] { 10000, 0, 0, 0 });
thicknessBox.Name = "thicknessBox";
thicknessBox.Size = new System.Drawing.Size(100, 25);
thicknessBox.Size = new System.Drawing.Size(160, 25);
thicknessBox.TabIndex = 9;
thicknessBox.TextAlign = System.Windows.Forms.HorizontalAlignment.Right;
thicknessBox.ValueChanged += Details_Changed;
thicknessBox.TextChanged += Details_Changed;
//
// cutTypeLabel
//
@@ -321,7 +316,6 @@ namespace OpenNest.Forms
Shown += SaveToDrawingLibraryForm_Shown;
layoutTable.ResumeLayout(false);
layoutTable.PerformLayout();
((System.ComponentModel.ISupportInitialize)thicknessBox).EndInit();
buttonPanel.ResumeLayout(false);
buttonPanel.PerformLayout();
ResumeLayout(false);
@@ -339,7 +333,7 @@ namespace OpenNest.Forms
private System.Windows.Forms.Label gradeLabel;
private System.Windows.Forms.TextBox gradeBox;
private System.Windows.Forms.Label thicknessLabel;
private System.Windows.Forms.NumericUpDown thicknessBox;
private OpenNest.Controls.ThicknessComboBox thicknessBox;
private System.Windows.Forms.Label cutTypeLabel;
private System.Windows.Forms.ComboBox cutTypeBox;
private System.Windows.Forms.Label notesLabel;
+6 -2
View File
@@ -53,7 +53,8 @@ namespace OpenNest.Forms
customerBox.Text = metadata.Customer;
materialBox.Text = metadata.MaterialName;
gradeBox.Text = metadata.MaterialGrade;
thicknessBox.Value = System.Math.Min(thicknessBox.Maximum, (decimal)metadata.Thickness);
thicknessBox.Units = metadata.Units;
thicknessBox.Value = metadata.Thickness;
thicknessLabel.Text = $"Thickness ({UnitsHelper.GetShortString(metadata.Units)}):";
cutTypeBox.SelectedItem = metadata.CutType;
notesBox.Text = metadata.Notes;
@@ -241,7 +242,10 @@ namespace OpenNest.Forms
Customer = customerBox.Text.Trim(),
MaterialName = materialBox.Text.Trim(),
MaterialGrade = gradeBox.Text.Trim(),
Thickness = DrawingLibraryRules.NormalizeThickness((double)thicknessBox.Value),
// Unreadable text fails the metadata rules instead of reading as 0.
Thickness = thicknessBox.TryGetValue(out var thickness)
? DrawingLibraryRules.NormalizeThickness(thickness)
: double.NaN,
Units = session.Nest.Units,
CutType = cutTypeBox.SelectedItem is CutType cutType ? cutType : CutType.Laser,
Notes = notesBox.Text,