refactor(bom): build import rows, groups and nests in OpenNest.IO

Move the BOM import dialog's row building, grouping and per-group nest
construction out of BomImportForm into OpenNest.IO/Bom so they can be
tested on Linux:

- BomPartRow (now public) and BomImportRows.Build, a verbatim move of
  the form's BuildPartRows (still via BomAnalyzer).
- BomImportGroups.Build: one grouping used by both the Groups tab and
  Create Nests, which each carried their own copy. Create Nests now
  creates nests in the Groups tab's order (material, then thickness).
- BomNestBuilder.Build: the nest for one group (saved defaults first,
  then the group's plate size, spacing, material and thickness; one
  drawing per row with the row's quantity, 1 when blank).

The form keeps the file dialogs, grids, EditNestForm windows and the
completion message. Behavior is otherwise unchanged; the two row-status
defects found while planning are fixed in the following commits.
This commit is contained in:
aj committed 2026-10-05 17:45:16 -04:00
1 parent 03b3ed47b2
commit ca153a1c20
8 files changed
+603 -178

No files matched your search

+66
View File
@@ -0,0 +1,66 @@
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.IO.Bom;
/// <summary>
/// The rows of one material and thickness; each group becomes one nest.
/// </summary>
public sealed class BomImportGroup
{
public BomImportGroup(string material, double thickness, IReadOnlyList<BomPartRow> parts)
{
Material = material;
Thickness = thickness;
Parts = parts;
}
/// <summary>The material as the group's first row spells it.</summary>
public string Material { get; }
public double Thickness { get; }
public IReadOnlyList<BomPartRow> Parts { get; }
/// <summary>Sum of the rows' quantities; a blank quantity counts as 0.</summary>
public int TotalQty => Parts.Sum(p => p.Qty ?? 0);
public string Key => BomImportGroups.Key(Material, Thickness);
}
/// <summary>
/// Groups importable rows by material and thickness. The Groups tab and
/// Create Nests both use this, so they always agree.
/// </summary>
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.
/// </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)
)
.GroupBy(p => new
{
Material = p.Material.ToUpperInvariant(),
Thickness = p.Thickness.Value,
})
.OrderBy(g => g.First().Material)
.ThenBy(g => g.Key.Thickness)
.Select(g => new BomImportGroup(g.First().Material, g.Key.Thickness, g.ToList()))
.ToList();
}
/// <summary>
/// The key that identifies a group's plate settings across regrouping.
/// </summary>
public static string Key(string material, double thickness) =>
$"{material?.ToUpperInvariant()}|{thickness}";
}
+73
View File
@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace OpenNest.IO.Bom;
/// <summary>
/// Builds the import dialog's part rows from the BOM items and the drawing
/// folder.
/// </summary>
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.
/// </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[part.Item.FileName ?? ""] = part.DxfPath;
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
{
var lookupName = item.FileName;
if (
lookupName.EndsWith(".dxf", StringComparison.OrdinalIgnoreCase)
|| lookupName.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase)
)
lookupName = Path.GetFileNameWithoutExtension(lookupName);
if (matchedPaths.TryGetValue(lookupName, out var dxfPath))
{
row.DxfPath = dxfPath;
row.Status = "Matched";
row.IsEditable = true;
}
else
{
row.Status = "No DXF";
row.IsEditable = false;
}
}
rows.Add(row);
}
return rows;
}
}
+115
View File
@@ -0,0 +1,115 @@
using System;
using System.Collections.Generic;
using System.IO;
using OpenNest.Geometry;
namespace OpenNest.IO.Bom;
/// <summary>Plate size and spacing for one group's nest.</summary>
public sealed class BomGroupPlateSettings
{
public double PlateWidth { get; set; }
public double PlateLength { get; set; }
public double PartSpacing { get; set; }
public double EdgeLeft { get; set; }
public double EdgeBottom { get; set; }
public double EdgeRight { get; set; }
public double EdgeTop { get; set; }
}
/// <summary>
/// The nest built for one group, or none when no drawing imported, plus a
/// message for every drawing that could not be imported.
/// </summary>
public sealed class BomNestBuildResult
{
public BomNestBuildResult(Nest nest, IReadOnlyList<string> errors)
{
Nest = nest;
Errors = errors;
}
/// <summary>The new nest, or null when none of the group's drawings imported.</summary>
public Nest Nest { get; }
public IReadOnlyList<string> Errors { get; }
}
/// <summary>Builds the nest for one BOM import group.</summary>
public static class BomNestBuilder
{
/// <summary>
/// Creates a nest named "<paramref name="jobName"/> - thickness material".
/// <paramref name="applySavedDefaults"/> runs first (units, quadrant,
/// plate), then the group's plate size and spacing, material and
/// thickness are set. Each row's drawing is imported with the row's
/// quantity (1 when blank). The nest gets one plate when at least one
/// drawing imported.
/// </summary>
public static BomNestBuildResult Build(
BomImportGroup group,
BomGroupPlateSettings plate,
string jobName,
Action<Nest> applySavedDefaults
)
{
ArgumentNullException.ThrowIfNull(group);
ArgumentNullException.ThrowIfNull(plate);
var material = group.Material;
var thickness = group.Thickness;
var errors = new List<string>();
var nest = new Nest($"{jobName} - {thickness:0.###} {material}");
nest.DateCreated = DateTime.Now;
nest.DateLastModified = DateTime.Now;
// Saved defaults first (units, quadrant, plate), as New does;
// then the group's own plate size and spacing.
applySavedDefaults?.Invoke(nest);
nest.PlateDefaults.Size = new Size(plate.PlateWidth, plate.PlateLength);
nest.Thickness = thickness;
nest.Material = new Material(material);
nest.PlateDefaults.PartSpacing = plate.PartSpacing;
nest.PlateDefaults.EdgeSpacing = new Spacing(
plate.EdgeLeft,
plate.EdgeBottom,
plate.EdgeRight,
plate.EdgeTop
);
foreach (var part in group.Parts)
{
if (!File.Exists(part.DxfPath))
{
errors.Add($"{part.FileName}: DXF file not found");
continue;
}
try
{
var drawing = CadImporter.ImportDrawing(
part.DxfPath,
new CadImportOptions { Quantity = part.Qty ?? 1 }
);
drawing.Material = new Material(material);
nest.Drawings.Add(drawing);
}
catch (Exception ex)
{
errors.Add($"{part.FileName}: {ex.Message}");
}
}
if (nest.Drawings.Count == 0)
return new BomNestBuildResult(null, errors);
nest.CreatePlate();
return new BomNestBuildResult(nest, errors);
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace OpenNest.IO.Bom;
/// <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
/// imported.
/// </summary>
public class BomPartRow
{
public int? ItemNum { get; set; }
public string FileName { get; set; }
public int? Qty { get; set; }
public string Description { get; set; }
public string Material { get; set; }
public double? Thickness { get; set; }
public string DxfPath { get; set; }
public string Status { get; set; }
public bool IsEditable { get; set; }
}