diff --git a/OpenNest.Data/StandardThicknesses.cs b/OpenNest.Data/StandardThicknesses.cs
new file mode 100644
index 00000000..24ebcf2b
--- /dev/null
+++ b/OpenNest.Data/StandardThicknesses.cs
@@ -0,0 +1,128 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+
+namespace OpenNest.Data;
+
+///
+/// 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.
+///
+public static partial class StandardThicknesses
+{
+ /// One listed thickness: its name ("16 ga", "1/4\"") and value in inches.
+ 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",
+ };
+
+ /// Gauges thinnest first, then plate fractions thinnest first.
+ public static IReadOnlyList 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();
+
+ /// The value of in .
+ public static double ValueIn(Entry entry, Units units) =>
+ units == Units.Millimeters ? entry.Inches * MillimetersPerInch : entry.Inches;
+
+ /// The list text for , e.g. "16 ga - 0.0598".
+ public static string Label(Entry entry, Units units) =>
+ $"{entry.Name} - {FormatNumber(ValueIn(entry, units), units)}";
+
+ ///
+ /// The text a thickness box shows for : the listed label when
+ /// it matches an entry to the displayed precision, otherwise the plain number.
+ ///
+ public static string Format(double value, Units units)
+ {
+ var entry = Find(value, units);
+ return entry != null ? Label(entry, units) : FormatNumber(value, units);
+ }
+
+ /// The entry matches to the displayed precision, or null.
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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);
+
+ /// Reads "3/16", "1-1/4" or "1 1/4\"" as inches; null when not a fraction.
+ 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(@"^(?:(?\d{1,3})(?:[\s-]+(?\d{1,4})/(?\d{1,4}))?|(?\d{1,4})/(?\d{1,4}))\s*""?$")]
+ private static partial Regex FractionPattern();
+
+ [GeneratedRegex(@"^(?:#\s*(?\d+)|(?\d+)\s*(?:ga|gauge|gage)\.?)$", RegexOptions.IgnoreCase)]
+ private static partial Regex GaugePattern();
+}
diff --git a/OpenNest.Tests/Data/StandardThicknessesTests.cs b/OpenNest.Tests/Data/StandardThicknessesTests.cs
new file mode 100644
index 00000000..00bda892
--- /dev/null
+++ b/OpenNest.Tests/Data/StandardThicknessesTests.cs
@@ -0,0 +1,72 @@
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+///
+/// The thickness box list: gauges and plate fractions read and display as their
+/// decimals, and any other typed decimal still works.
+///
+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);
+ }
+}
diff --git a/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs b/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs
index fc704ef6..1f2be4a1 100644
--- a/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs
+++ b/OpenNest.WinForms.Tests/Forms/EditNestInfoFormTests.cs
@@ -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")
diff --git a/OpenNest.WinForms.Tests/Forms/SaveToDrawingLibraryFormTests.cs b/OpenNest.WinForms.Tests/Forms/SaveToDrawingLibraryFormTests.cs
index d4613f39..60c99dfa 100644
--- a/OpenNest.WinForms.Tests/Forms/SaveToDrawingLibraryFormTests.cs
+++ b/OpenNest.WinForms.Tests/Forms/SaveToDrawingLibraryFormTests.cs
@@ -30,7 +30,7 @@ public class SaveToDrawingLibraryFormTests
Assert.Equal("SYN-101", Field(form, "nameBox").Text);
Assert.Equal("Synthetic Steel", Field(form, "materialBox").Text);
Assert.Equal("Grade A", Field(form, "gradeBox").Text);
- Assert.Equal(0.25m, Field(form, "thicknessBox").Value);
+ Assert.Equal("1/4\" - 0.25", Field(form, "thicknessBox").Text);
Assert.Equal(CutType.Waterjet, Field(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(form, "saveAsBox");
var save = Field