fix(bom): find the drawing for rows that still need a thickness

A BOM row with a file name and a matching drawing but a blank thickness
was reported "No DXF" and locked, so its thickness could not be entered
and the part was dropped: BomAnalyzer skips such items before looking
for a drawing, and the form only knew drawings the analyzer matched.
A row with a blank material read "Matched" but was silently left out
of every group.

Rows now resolve their drawing through a DrawingFileIndex shared with
BomAnalyzer (whose behavior and tests are unchanged), and a row's status
is computed from its values:

  Ready | Needs material | Needs thickness | No drawing found | No file name

Rows with a drawing stay editable, and an edit updates the status cell.
Groups take only Ready rows (a zero, negative or non-finite thickness is
not Ready). The summary line counts ready rows and each problem.

Build_RowWithoutThickness_StillFindsItsDrawing failed at its DxfPath
assertion against the previous commit. Mutation reds: removing the
material check, accepting zero or non-finite thickness, skipping the
drawing for rows without thickness, and grouping without the status
each fail the Bom tests.

Project Memory: b8e3a978-2cfe-4fe7-b2a5-eb01c6c54679
This commit is contained in:
aj committed 2026-10-05 17:50:54 -04:00
1 parent f4d1d45c81
commit b83aeb0e41
12 files changed
+315 -122

No files matched your search

+4 -29
View File
@@ -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<string, string>(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<MatchedPart>();
@@ -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 });
}
+4 -9
View File
@@ -35,18 +35,13 @@ public sealed class BomImportGroup
public static class BomImportGroups
{
/// <summary>
/// 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 <see cref="BomRowStatus.Ready"/> rows. Material
/// is compared case-insensitively. Groups are ordered by material, then
/// thickness.
/// </summary>
public static List<BomImportGroup> Build(IEnumerable<BomPartRow> 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(),
+16 -56
View File
@@ -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
{
/// <summary>
/// Returns one row per BOM item, in BOM order, with the drawing file it
/// matched in <paramref name="dxfFolder"/> 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 <paramref name="dxfFolder"/>,
/// whether or not its material and thickness are filled in.
/// </summary>
public static List<BomPartRow> Build(List<BomItem> items, string dxfFolder)
{
var analysis = BomAnalyzer.Analyze(items, dxfFolder);
var matchedPaths = new Dictionary<string, string>(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<BomPartRow>();
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;
}
/// <summary>
/// The drawing name a BOM file name refers to: the name without a
/// .dxf or .dwg extension, as <see cref="BomAnalyzer"/> matches it.
/// </summary>
private static string LookupName(string fileName)
{
fileName ??= "";
if (
fileName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| fileName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
)
return Path.GetFileNameWithoutExtension(fileName);
return fileName;
}
}
+30
View File
@@ -0,0 +1,30 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.IO.Bom;
/// <summary>The import dialog's one-line summary of the part rows.</summary>
public static class BomImportSummary
{
/// <summary>
/// For example "12 ready, 2 need a thickness, 1 no drawing found".
/// </summary>
public static string Describe(IEnumerable<BomPartRow> 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<string> { $"{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<string> parts, int count, string one, string many)
{
if (count > 0)
parts.Add($"{count} {(count == 1 ? one : many)}");
}
}
+55 -2
View File
@@ -1,5 +1,26 @@
using System;
namespace OpenNest.IO.Bom;
/// <summary>Whether a BOM row can be imported, and if not, why.</summary>
public enum BomRowStatus
{
/// <summary>Drawing found, material and thickness set: the row is imported.</summary>
Ready,
/// <summary>Drawing found but the material is blank.</summary>
NeedsMaterial,
/// <summary>Drawing found but the thickness is blank, zero or negative.</summary>
NeedsThickness,
/// <summary>No drawing file matches the row's file name.</summary>
NoDrawing,
/// <summary>The BOM row has no file name.</summary>
NoFileName,
}
/// <summary>
/// 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; }
/// <summary>The matched drawing file, or null when none was found.</summary>
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);
/// <summary>
/// True when the row has a drawing, so the operator can complete its
/// values; rows without one can never be imported.
/// </summary>
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),
};
}
+49
View File
@@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace OpenNest.IO.Bom;
/// <summary>
/// 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.
/// </summary>
internal sealed class DrawingFileIndex
{
private readonly Dictionary<string, string> 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);
/// <summary>
/// The drawing file for a BOM file name, ignoring case and a .dxf or
/// .dwg extension; null when there is none.
/// </summary>
public string Find(string bomFileName) =>
files.TryGetValue(LookupName(bomFileName), out var path) ? path : null;
/// <summary>The BOM file name without a .dxf or .dwg extension.</summary>
public static string LookupName(string fileName)
{
fileName ??= "";
if (
fileName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| fileName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
)
return Path.GetFileNameWithoutExtension(fileName);
return fileName;
}
}
+4 -5
View File
@@ -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<BomPartRow>
{
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,
};
}
+39 -3
View File
@@ -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<BomItem> { 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<BomItem> { 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<BomItem> { 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<BomItem> { 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);
}
@@ -0,0 +1,47 @@
using OpenNest.IO.Bom;
namespace OpenNest.Tests.Bom;
public class BomImportSummaryTests
{
[Fact]
public void Describe_CountsReadyRowsAndEachProblem()
{
var rows = new List<BomPartRow>
{
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<BomPartRow>()));
}
private static BomPartRow Row(
string? fileName,
string? material,
double? thickness,
string? dxfPath = "/d/part.dxf"
) =>
new()
{
FileName = fileName,
Material = material,
Thickness = thickness,
DxfPath = dxfPath,
};
}
@@ -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);
+61
View File
@@ -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<BomRowStatus>().Select(BomPartRow.Describe)
);
}
}
+6 -16
View File
@@ -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<string>();
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