diff --git a/OpenNest.IO/Bom/BomAnalyzer.cs b/OpenNest.IO/Bom/BomAnalyzer.cs index a595a0e..709ea51 100644 --- a/OpenNest.IO/Bom/BomAnalyzer.cs +++ b/OpenNest.IO/Bom/BomAnalyzer.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.IO; using System.Linq; namespace OpenNest.IO.Bom @@ -31,23 +29,8 @@ namespace OpenNest.IO.Bom { var result = new BomAnalysis(); - // Build a case-insensitive lookup of DXF files in the folder (if it exists) - var folderExists = Directory.Exists(dxfFolder); - var dxfFiles = new Dictionary(StringComparer.OrdinalIgnoreCase); - - if (folderExists) - { - foreach (var file in Directory.GetFiles(dxfFolder, "*.dxf")) - { - var nameWithoutExt = Path.GetFileNameWithoutExtension(file); - dxfFiles[nameWithoutExt] = file; - } - foreach (var file in Directory.GetFiles(dxfFolder, "*.dwg")) - { - var nameWithoutExt = Path.GetFileNameWithoutExtension(file); - dxfFiles.TryAdd(nameWithoutExt, file); - } - } + // Case-insensitive lookup of the drawing files in the folder (if it exists) + var drawings = DrawingFileIndex.Load(dxfFolder); // Partition items into: skipped, unmatched, or matched (grouped) var matched = new List(); @@ -60,20 +43,12 @@ namespace OpenNest.IO.Bom continue; } - var lookupName = item.FileName; - - if ( - lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) - || lookupName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase) - ) - lookupName = Path.GetFileNameWithoutExtension(lookupName); - - if (!folderExists) + if (!drawings.FolderExists) { // No folder to search — group items without a DXF path matched.Add(new MatchedPart { Item = item, DxfPath = null }); } - else if (dxfFiles.TryGetValue(lookupName, out var dxfPath)) + else if (drawings.Find(item.FileName) is { } dxfPath) { matched.Add(new MatchedPart { Item = item, DxfPath = dxfPath }); } diff --git a/OpenNest.IO/Bom/BomImportGroups.cs b/OpenNest.IO/Bom/BomImportGroups.cs index a09c40c..e295256 100644 --- a/OpenNest.IO/Bom/BomImportGroups.cs +++ b/OpenNest.IO/Bom/BomImportGroups.cs @@ -35,18 +35,13 @@ public sealed class BomImportGroup public static class BomImportGroups { /// - /// Returns the groups of editable rows that have a drawing, a material - /// and a thickness. Material is compared case-insensitively. Groups are - /// ordered by material, then thickness. + /// Returns the groups of rows. Material + /// is compared case-insensitively. Groups are ordered by material, then + /// thickness. /// public static List Build(IEnumerable rows) { - return rows.Where(p => - p.IsEditable - && !string.IsNullOrWhiteSpace(p.Material) - && p.Thickness.HasValue - && !string.IsNullOrWhiteSpace(p.DxfPath) - ) + return rows.Where(p => p.Status == BomRowStatus.Ready) .GroupBy(p => new { Material = p.Material.ToUpperInvariant(), diff --git a/OpenNest.IO/Bom/BomImportRows.cs b/OpenNest.IO/Bom/BomImportRows.cs index d96c24a..a127905 100644 --- a/OpenNest.IO/Bom/BomImportRows.cs +++ b/OpenNest.IO/Bom/BomImportRows.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.IO; namespace OpenNest.IO.Bom; @@ -11,71 +9,33 @@ namespace OpenNest.IO.Bom; public static class BomImportRows { /// - /// Returns one row per BOM item, in BOM order, with the drawing file it - /// matched in and its import status. + /// Returns one row per BOM item, in BOM order. Every row with a file + /// name gets the drawing it matches in , + /// whether or not its material and thickness are filled in. /// public static List Build(List items, string dxfFolder) { - var analysis = BomAnalyzer.Analyze(items, dxfFolder); - var matchedPaths = new Dictionary(StringComparer.OrdinalIgnoreCase); - - foreach (var group in analysis.Groups) - foreach (var part in group.Parts) - if (part.DxfPath != null) - matchedPaths[LookupName(part.Item.FileName)] = part.DxfPath; - + var index = DrawingFileIndex.Load(dxfFolder); var rows = new List(); foreach (var item in items) { - var row = new BomPartRow - { - ItemNum = item.ItemNum, - FileName = item.FileName, - Qty = item.Qty, - Description = item.Description, - Material = item.Material, - Thickness = item.Thickness, - }; - - if (string.IsNullOrWhiteSpace(item.FileName)) - { - row.Status = "Skipped"; - row.IsEditable = false; - } - else - { - if (matchedPaths.TryGetValue(LookupName(item.FileName), out var dxfPath)) + rows.Add( + new BomPartRow { - row.DxfPath = dxfPath; - row.Status = "Matched"; - row.IsEditable = true; + ItemNum = item.ItemNum, + FileName = item.FileName, + Qty = item.Qty, + Description = item.Description, + Material = item.Material, + Thickness = item.Thickness, + DxfPath = string.IsNullOrWhiteSpace(item.FileName) + ? null + : index.Find(item.FileName), } - else - { - row.Status = "No DXF"; - row.IsEditable = false; - } - } - - rows.Add(row); + ); } return rows; } - - /// - /// The drawing name a BOM file name refers to: the name without a - /// .dxf or .dwg extension, as matches it. - /// - private static string LookupName(string fileName) - { - fileName ??= ""; - if ( - fileName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) - || fileName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase) - ) - return Path.GetFileNameWithoutExtension(fileName); - return fileName; - } } diff --git a/OpenNest.IO/Bom/BomImportSummary.cs b/OpenNest.IO/Bom/BomImportSummary.cs new file mode 100644 index 0000000..34d37fb --- /dev/null +++ b/OpenNest.IO/Bom/BomImportSummary.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.IO.Bom; + +/// The import dialog's one-line summary of the part rows. +public static class BomImportSummary +{ + /// + /// For example "12 ready, 2 need a thickness, 1 no drawing found". + /// + public static string Describe(IEnumerable rows) + { + var counts = rows.GroupBy(r => r.Status).ToDictionary(g => g.Key, g => g.Count()); + int Count(BomRowStatus status) => counts.TryGetValue(status, out var n) ? n : 0; + + var parts = new List { $"{Count(BomRowStatus.Ready)} ready" }; + Add(parts, Count(BomRowStatus.NeedsMaterial), "needs a material", "need a material"); + Add(parts, Count(BomRowStatus.NeedsThickness), "needs a thickness", "need a thickness"); + Add(parts, Count(BomRowStatus.NoDrawing), "no drawing found", "no drawing found"); + Add(parts, Count(BomRowStatus.NoFileName), "no file name", "no file name"); + return string.Join(", ", parts); + } + + private static void Add(List parts, int count, string one, string many) + { + if (count > 0) + parts.Add($"{count} {(count == 1 ? one : many)}"); + } +} diff --git a/OpenNest.IO/Bom/BomPartRow.cs b/OpenNest.IO/Bom/BomPartRow.cs index ccd7f86..9c2dd53 100644 --- a/OpenNest.IO/Bom/BomPartRow.cs +++ b/OpenNest.IO/Bom/BomPartRow.cs @@ -1,5 +1,26 @@ +using System; + namespace OpenNest.IO.Bom; +/// Whether a BOM row can be imported, and if not, why. +public enum BomRowStatus +{ + /// Drawing found, material and thickness set: the row is imported. + Ready, + + /// Drawing found but the material is blank. + NeedsMaterial, + + /// Drawing found but the thickness is blank, zero or negative. + NeedsThickness, + + /// No drawing file matches the row's file name. + NoDrawing, + + /// The BOM row has no file name. + NoFileName, +} + /// /// One BOM line as the import dialog shows and edits it: the values read /// from the BOM, the drawing file it resolved to and whether it can be @@ -19,9 +40,41 @@ public class BomPartRow public double? Thickness { get; set; } + /// The matched drawing file, or null when none was found. public string DxfPath { get; set; } - public string Status { get; set; } + public BomRowStatus Status + { + get + { + if (string.IsNullOrWhiteSpace(FileName)) + return BomRowStatus.NoFileName; + if (string.IsNullOrWhiteSpace(DxfPath)) + return BomRowStatus.NoDrawing; + if (string.IsNullOrWhiteSpace(Material)) + return BomRowStatus.NeedsMaterial; + if (Thickness is not double thickness || !double.IsFinite(thickness) || thickness <= 0) + return BomRowStatus.NeedsThickness; + return BomRowStatus.Ready; + } + } - public bool IsEditable { get; set; } + public string StatusText => Describe(Status); + + /// + /// True when the row has a drawing, so the operator can complete its + /// values; rows without one can never be imported. + /// + public bool IsEditable => Status is not (BomRowStatus.NoFileName or BomRowStatus.NoDrawing); + + public static string Describe(BomRowStatus status) => + status switch + { + BomRowStatus.Ready => "Ready", + BomRowStatus.NeedsMaterial => "Needs material", + BomRowStatus.NeedsThickness => "Needs thickness", + BomRowStatus.NoDrawing => "No drawing found", + BomRowStatus.NoFileName => "No file name", + _ => throw new ArgumentOutOfRangeException(nameof(status), status, null), + }; } diff --git a/OpenNest.IO/Bom/DrawingFileIndex.cs b/OpenNest.IO/Bom/DrawingFileIndex.cs new file mode 100644 index 0000000..2099b14 --- /dev/null +++ b/OpenNest.IO/Bom/DrawingFileIndex.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace OpenNest.IO.Bom; + +/// +/// The .dxf and .dwg files in one folder, looked up by the name a BOM uses. +/// A .dxf wins over a .dwg with the same name. +/// +internal sealed class DrawingFileIndex +{ + private readonly Dictionary files = new(StringComparer.OrdinalIgnoreCase); + + private DrawingFileIndex(string folder) + { + FolderExists = Directory.Exists(folder); + if (!FolderExists) + return; + + foreach (var file in Directory.GetFiles(folder, "*.dxf")) + files[Path.GetFileNameWithoutExtension(file)] = file; + foreach (var file in Directory.GetFiles(folder, "*.dwg")) + files.TryAdd(Path.GetFileNameWithoutExtension(file), file); + } + + public bool FolderExists { get; } + + public static DrawingFileIndex Load(string folder) => new(folder); + + /// + /// The drawing file for a BOM file name, ignoring case and a .dxf or + /// .dwg extension; null when there is none. + /// + public string Find(string bomFileName) => + files.TryGetValue(LookupName(bomFileName), out var path) ? path : null; + + /// The BOM file name without a .dxf or .dwg extension. + public static string LookupName(string fileName) + { + fileName ??= ""; + if ( + fileName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase) + ) + return Path.GetFileNameWithoutExtension(fileName); + return fileName; + } +} diff --git a/OpenNest.Tests/Bom/BomImportGroupsTests.cs b/OpenNest.Tests/Bom/BomImportGroupsTests.cs index 2ac2d6c..98548a4 100644 --- a/OpenNest.Tests/Bom/BomImportGroupsTests.cs +++ b/OpenNest.Tests/Bom/BomImportGroupsTests.cs @@ -31,17 +31,18 @@ public class BomImportGroupsTests [Fact] public void Build_LeavesOutRowsThatCannotBeImported() { - var locked = Row("PT02", "Stainless", 0.25); - locked.IsEditable = false; + var noFileName = Row("PT02", "Stainless", 0.25); + noFileName.FileName = " "; var noDrawing = Row("PT05", "Stainless", 0.25); noDrawing.DxfPath = null; var rows = new List { Row("PT01", "Stainless", 0.25), - locked, + noFileName, Row("PT03", " ", 0.25), Row("PT04", "Stainless", null), noDrawing, + Row("PT06", "Stainless", 0), }; var groups = BomImportGroups.Build(rows); @@ -65,7 +66,5 @@ public class BomImportGroupsTests Thickness = thickness, Qty = qty, DxfPath = $"/drawings/{fileName}.dxf", - Status = "Matched", - IsEditable = true, }; } diff --git a/OpenNest.Tests/Bom/BomImportRowsTests.cs b/OpenNest.Tests/Bom/BomImportRowsTests.cs index 0e51efc..fdedb09 100644 --- a/OpenNest.Tests/Bom/BomImportRowsTests.cs +++ b/OpenNest.Tests/Bom/BomImportRowsTests.cs @@ -36,7 +36,16 @@ public sealed class BomImportRowsTests : IDisposable var rows = BomImportRows.Build(items, folder); Assert.Equal(new[] { 1, 2, 3, 4 }, rows.Select(r => r.ItemNum ?? 0)); - Assert.Equal(new[] { "Matched", "No DXF", "Skipped", "Matched" }, rows.Select(r => r.Status)); + Assert.Equal( + new[] + { + BomRowStatus.Ready, + BomRowStatus.NoDrawing, + BomRowStatus.NoFileName, + BomRowStatus.Ready, + }, + rows.Select(r => r.Status) + ); Assert.Equal(new[] { true, false, false, true }, rows.Select(r => r.IsEditable)); Assert.Equal(Path.Combine(folder, "PT01.dxf"), rows[0].DxfPath); Assert.Null(rows[1].DxfPath); @@ -57,7 +66,7 @@ public sealed class BomImportRowsTests : IDisposable var rows = BomImportRows.Build(new List { Item("pt01") }, folder); - Assert.Equal("Matched", rows[0].Status); + Assert.Equal(BomRowStatus.Ready, rows[0].Status); Assert.Equal(Path.Combine(folder, "PT01.dxf"), rows[0].DxfPath); } @@ -77,6 +86,33 @@ public sealed class BomImportRowsTests : IDisposable Assert.Equal(Path.Combine(folder, "PT02.dwg"), rows[1].DxfPath); } + [Fact] + public void Build_RowWithoutThickness_StillFindsItsDrawing() + { + Touch("PT01.dxf"); + var item = Item("PT01"); + item.Thickness = null; + + var rows = BomImportRows.Build(new List { item }, folder); + + Assert.Equal(Path.Combine(folder, "PT01.dxf"), rows[0].DxfPath); + Assert.True(rows[0].IsEditable); + Assert.Equal(BomRowStatus.NeedsThickness, rows[0].Status); + } + + [Fact] + public void Build_RowWithoutMaterial_NeedsAMaterial() + { + Touch("PT01.dxf"); + var item = Item("PT01"); + item.Material = " "; + + var rows = BomImportRows.Build(new List { item }, folder); + + Assert.True(rows[0].IsEditable); + Assert.Equal(BomRowStatus.NeedsMaterial, rows[0].Status); + } + [Fact] public void Build_WithMissingFolder_FindsNoDrawings() { @@ -84,7 +120,7 @@ public sealed class BomImportRowsTests : IDisposable var rows = BomImportRows.Build(new List { Item("PT01") }, missing); - Assert.Equal("No DXF", rows[0].Status); + Assert.Equal(BomRowStatus.NoDrawing, rows[0].Status); Assert.False(rows[0].IsEditable); Assert.Null(rows[0].DxfPath); } diff --git a/OpenNest.Tests/Bom/BomImportSummaryTests.cs b/OpenNest.Tests/Bom/BomImportSummaryTests.cs new file mode 100644 index 0000000..b159622 --- /dev/null +++ b/OpenNest.Tests/Bom/BomImportSummaryTests.cs @@ -0,0 +1,47 @@ +using OpenNest.IO.Bom; + +namespace OpenNest.Tests.Bom; + +public class BomImportSummaryTests +{ + [Fact] + public void Describe_CountsReadyRowsAndEachProblem() + { + var rows = new List + { + Row("PT01", "Steel", 0.25), + Row("PT02", "Steel", 0.25), + Row("PT03", null, 0.25), + Row("PT04", "Steel", null), + Row("PT05", "Steel", null), + Row("PT06", "Steel", 0.25, dxfPath: null), + Row(null, "Steel", 0.25), + }; + + Assert.Equal( + "2 ready, 1 needs a material, 2 need a thickness, 1 no drawing found, 1 no file name", + BomImportSummary.Describe(rows) + ); + } + + [Fact] + public void Describe_WithEveryRowReady_ListsOnlyTheReadyCount() + { + Assert.Equal("1 ready", BomImportSummary.Describe(new[] { Row("PT01", "Steel", 0.25) })); + Assert.Equal("0 ready", BomImportSummary.Describe(Array.Empty())); + } + + private static BomPartRow Row( + string? fileName, + string? material, + double? thickness, + string? dxfPath = "/d/part.dxf" + ) => + new() + { + FileName = fileName, + Material = material, + Thickness = thickness, + DxfPath = dxfPath, + }; +} diff --git a/OpenNest.Tests/Bom/BomNestBuilderTests.cs b/OpenNest.Tests/Bom/BomNestBuilderTests.cs index 4be604f..98075fa 100644 --- a/OpenNest.Tests/Bom/BomNestBuilderTests.cs +++ b/OpenNest.Tests/Bom/BomNestBuilderTests.cs @@ -94,8 +94,6 @@ public sealed class BomNestBuilderTests : IDisposable Material = "Stainless", Thickness = 0.25, DxfPath = WriteSquare(name), - Status = "Matched", - IsEditable = true, }; private static BomImportGroup Group(params BomPartRow[] rows) => new("Stainless", 0.25, rows); diff --git a/OpenNest.Tests/Bom/BomPartRowTests.cs b/OpenNest.Tests/Bom/BomPartRowTests.cs new file mode 100644 index 0000000..7c96b7d --- /dev/null +++ b/OpenNest.Tests/Bom/BomPartRowTests.cs @@ -0,0 +1,61 @@ +using OpenNest.IO.Bom; + +namespace OpenNest.Tests.Bom; + +public class BomPartRowTests +{ + [Theory] + [InlineData(null, "/d/PT01.dxf", "Steel", 0.25, BomRowStatus.NoFileName)] + [InlineData(" ", "/d/PT01.dxf", "Steel", 0.25, BomRowStatus.NoFileName)] + [InlineData("PT01", null, "Steel", 0.25, BomRowStatus.NoDrawing)] + [InlineData("PT01", "/d/PT01.dxf", null, 0.25, BomRowStatus.NeedsMaterial)] + [InlineData("PT01", "/d/PT01.dxf", "\t", 0.25, BomRowStatus.NeedsMaterial)] + [InlineData("PT01", "/d/PT01.dxf", "Steel", null, BomRowStatus.NeedsThickness)] + [InlineData("PT01", "/d/PT01.dxf", "Steel", 0.0, BomRowStatus.NeedsThickness)] + [InlineData("PT01", "/d/PT01.dxf", "Steel", -0.25, BomRowStatus.NeedsThickness)] + [InlineData("PT01", "/d/PT01.dxf", "Steel", double.NaN, BomRowStatus.NeedsThickness)] + [InlineData("PT01", "/d/PT01.dxf", "Steel", double.PositiveInfinity, BomRowStatus.NeedsThickness)] + [InlineData("PT01", "/d/PT01.dxf", "Steel", 0.25, BomRowStatus.Ready)] + public void Status_FollowsTheFirstMissingValue( + string? fileName, + string? dxfPath, + string? material, + double? thickness, + BomRowStatus expected + ) + { + var row = new BomPartRow + { + FileName = fileName, + DxfPath = dxfPath, + Material = material, + Thickness = thickness, + }; + + Assert.Equal(expected, row.Status); + Assert.Equal(expected is not (BomRowStatus.NoFileName or BomRowStatus.NoDrawing), row.IsEditable); + } + + [Fact] + public void Status_ChangesWhenTheOperatorFillsInAValue() + { + var row = new BomPartRow { FileName = "PT01", DxfPath = "/d/PT01.dxf" }; + Assert.Equal(BomRowStatus.NeedsMaterial, row.Status); + + row.Material = "Steel"; + Assert.Equal(BomRowStatus.NeedsThickness, row.Status); + + row.Thickness = 0.25; + Assert.Equal(BomRowStatus.Ready, row.Status); + Assert.Equal("Ready", row.StatusText); + } + + [Fact] + public void Describe_NamesEveryStatus() + { + Assert.Equal( + new[] { "Ready", "Needs material", "Needs thickness", "No drawing found", "No file name" }, + Enum.GetValues().Select(BomPartRow.Describe) + ); + } +} diff --git a/OpenNest/Forms/BomImportForm.cs b/OpenNest/Forms/BomImportForm.cs index faa61e0..183ae69 100644 --- a/OpenNest/Forms/BomImportForm.cs +++ b/OpenNest/Forms/BomImportForm.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Data; using System.Drawing; using System.IO; -using System.Linq; using System.Windows.Forms; using OpenNest.Data; using OpenNest.IO; @@ -145,7 +144,7 @@ namespace OpenNest.Forms part.Description ?? "", part.Material ?? "", part.Thickness?.ToString("0.####") ?? "", - part.Status + part.StatusText ); } @@ -196,6 +195,10 @@ namespace OpenNest.Forms part.Thickness = double.TryParse(text, out var t) ? t : (double?)null; } + _suppressRegroup = true; + dgvParts.Rows[e.RowIndex].Cells["Status"].Value = part.StatusText; + _suppressRegroup = false; + RebuildGroups(); UpdateSummary(); } @@ -307,20 +310,7 @@ namespace OpenNest.Forms private void UpdateSummary() { - var skipped = _parts.Count(p => p.Status == "Skipped"); - var noDxf = _parts.Count(p => p.Status == "No DXF"); - var matched = _parts.Count(p => p.Status == "Matched"); - - var summaryParts = new List(); - if (skipped > 0) - summaryParts.Add($"{skipped} skipped (no file name)"); - if (noDxf > 0) - summaryParts.Add($"{noDxf} no DXF found"); - - lblSummary.Text = - summaryParts.Count > 0 - ? string.Join(", ", summaryParts) - : $"{matched} parts matched"; + lblSummary.Text = BomImportSummary.Describe(_parts); } #endregion