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