feat(desktop): import a BOM into a drawing set

Drawing Sets > More > Import BOM reads a BOM and its drawing folder,
matches each drawing to the library by its D4 identity and geometry
(reuse, new revision, or new drawing) and opens the set editor with a
new set prefilled with the BOM quantities. Reading only queries the
library; Create Set lists the uploads and revisions and asks before
storing them, and the set is stored only by the editor's Save.

Parts with no material or thickness still import and are flagged for
Edit Drawing. DrawingPackage.GeometryKey compares geometry independent
of the fresh entity ids each DXF import assigns. In Database mode File >
Import BOM opens this flow instead of creating nests directly; File mode
is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-24 15:33:13 -04:00
1 parent a9198e5c25
commit 87672543cd
19 files changed
+2055 -15

No files matched your search

+319
View File
@@ -0,0 +1,319 @@
using System.Security.Cryptography;
namespace OpenNest.Data;
/// <summary>A drawing to import into a set: its library details, package and quantity per set.</summary>
public sealed class DrawingSetImportItem
{
public DrawingSetImportItem(string label, DrawingLibraryRecord metadata, byte[] package, long quantity)
{
Label = label ?? "";
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
Package = package ?? throw new ArgumentNullException(nameof(package));
Quantity = quantity;
}
/// <summary>How the operator knows the drawing, such as "PT01 (items 3, 7)".</summary>
public string Label { get; }
/// <summary>The details a new library drawing gets; customer, name, material, grade, thickness, units and cut type are its identity (D4).</summary>
public DrawingLibraryRecord Metadata { get; }
/// <summary>The canonical drawing package, compared by geometry with the library's.</summary>
public byte[] Package { get; }
public long Quantity { get; }
}
/// <summary>What an import does with one drawing.</summary>
public enum DrawingSetImportAction
{
/// <summary>An active library drawing with the same identity holds the same geometry: it is used as it is.</summary>
Reuse,
/// <summary>One active library drawing has the same identity but other geometry: it gains the next revision.</summary>
Revise,
/// <summary>No active library drawing has this identity: a new one is uploaded.</summary>
Create,
/// <summary>The drawing cannot be imported; <see cref="DrawingSetImportEntry.Reason"/> says why.</summary>
Skip,
}
/// <summary>One planned drawing: the action and the library drawing it uses or revises.</summary>
public sealed class DrawingSetImportEntry
{
internal DrawingSetImportEntry(DrawingSetImportItem item, DrawingSetImportAction action,
DrawingLibraryRecord? existing, string reason, Guid newId = default)
{
Item = item;
Action = action;
Existing = existing;
Reason = reason;
NewId = newId;
}
public DrawingSetImportItem Item { get; }
public DrawingSetImportAction Action { get; }
/// <summary>The library drawing reused or revised, as listed when planned.</summary>
public DrawingLibraryRecord? Existing { get; }
/// <summary>Why a skipped drawing is left out; empty otherwise.</summary>
public string Reason { get; }
/// <summary>The id a created drawing is uploaded under, allocated when planned.</summary>
public Guid NewId { get; }
/// <summary>The operator-facing account of the action, such as "New revision 4 (geometry changed)".</summary>
public string Describe() => Action switch
{
DrawingSetImportAction.Reuse => $"In the library (revision {Existing!.Revision})",
DrawingSetImportAction.Revise => $"New revision {Existing!.Revision + 1} (geometry changed)",
DrawingSetImportAction.Create => "New library drawing",
_ => "Not imported: " + Reason,
};
}
/// <summary>A library write failed part-way; <see cref="Written"/> lists what was stored before it.</summary>
public sealed class DrawingSetImportException : IOException
{
internal DrawingSetImportException(string message, IReadOnlyList<DrawingLibraryRecord> written, Exception inner)
: base(message, inner)
{
Written = written;
}
public IReadOnlyList<DrawingLibraryRecord> Written { get; }
}
/// <summary>
/// Imports drawings (for example a BOM's) into a new drawing set through the library.
/// <see cref="PlanAsync"/> only reads: it matches each drawing to the active library
/// drawings with its identity (D4: normalized customer, name, material and grade, exact
/// thickness, units and cut type) and compares geometry. Same identity and
/// geometry: reuse the drawing. Same identity, other geometry: the next revision of that
/// drawing. No match: a new drawing. Several matches with none holding the geometry are
/// skipped, as are invalid items and a second item with the identity of an earlier one.
/// <para>
/// <see cref="ApplyAsync"/> is the only step that writes, and only the uploads and
/// revisions the plan lists; the caller confirms them first. It then adds every
/// imported drawing to a <see cref="DrawingSetEditSession"/>, which stores nothing
/// until the operator saves the set. A failed write stops the import with the drawings
/// already stored listed; planning the same drawings again reuses them.
/// </para>
/// </summary>
public sealed class DrawingSetImport
{
private DrawingSetImport(IReadOnlyList<DrawingSetImportEntry> entries)
{
Entries = entries;
}
/// <summary>Every item, in the order given.</summary>
public IReadOnlyList<DrawingSetImportEntry> Entries { get; }
public IEnumerable<DrawingSetImportEntry> Imported => Entries.Where(e => e.Action != DrawingSetImportAction.Skip);
public int Count(DrawingSetImportAction action) => Entries.Count(e => e.Action == action);
/// <summary>True when applying stores anything in the library.</summary>
public bool HasWrites => Entries.Any(e => e.Action is DrawingSetImportAction.Create or DrawingSetImportAction.Revise);
/// <param name="geometryKey">
/// A key equal for packages with the same geometry (<c>OpenNest.IO.DrawingPackage.GeometryKey</c>):
/// a file read from another folder records another path, and older packages carry random
/// entity ids, so equal hashes are not required. It may
/// throw <see cref="InvalidDataException"/> for an unreadable package.
/// </param>
public static async Task<DrawingSetImport> PlanAsync(IDrawingLibrary library, IEnumerable<DrawingSetImportItem> items,
Func<byte[], string> geometryKey, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(library);
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(geometryKey);
var entries = new List<DrawingSetImportEntry>();
var planned = new List<DrawingSetImportItem>();
foreach (var item in items)
{
var reason = DrawingLibraryRules.GetMetadataError(item.Metadata)
?? DrawingLibraryRules.GetPackageError(item.Package)
?? (item.Quantity < 1 || item.Quantity > DrawingSetRules.MaxQuantity
? $"quantity {item.Quantity:N0} is outside 1 to {DrawingSetRules.MaxQuantity:N0}"
: null);
var twin = planned.FirstOrDefault(p => DrawingLibraryDuplicates.SameDuplicateKey(p.Metadata, item.Metadata));
if (reason == null && twin != null)
reason = $"same name and details as {twin.Label}; give one of them another material or thickness";
if (reason != null)
{
entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Skip, null, reason));
continue;
}
planned.Add(item);
var matches = await DrawingLibraryDuplicates.FindMatchesAsync(library, item.Metadata, cancellationToken);
var same = await FindSameGeometryAsync(library, item, matches, geometryKey, cancellationToken);
if (same != null)
entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Reuse, same, ""));
else if (matches.Count == 1)
entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Revise, matches[0], ""));
else if (matches.Count > 1)
entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Skip, null,
$"{matches.Count} library drawings have these details; add the right one in the set editor"));
else
entries.Add(new DrawingSetImportEntry(item, DrawingSetImportAction.Create, null, "", Guid.NewGuid()));
}
return new DrawingSetImport(entries);
}
// The first match holding the item's geometry: by package hash, otherwise by downloading
// the match's current package and comparing geometry keys.
private static async Task<DrawingLibraryRecord?> FindSameGeometryAsync(IDrawingLibrary library,
DrawingSetImportItem item, IReadOnlyList<DrawingLibraryRecord> matches, Func<byte[], string> geometryKey,
CancellationToken cancellationToken)
{
if (matches.Count == 0)
return null;
var hash = Sha256(item.Package);
var same = matches.FirstOrDefault(m => string.Equals(m.PackageSha256, hash, StringComparison.OrdinalIgnoreCase));
if (same != null)
return same;
var key = geometryKey(item.Package);
foreach (var match in matches)
{
var stored = await library.GetRevisionPackageAsync(match.Id, match.Revision, cancellationToken);
if (stored == null)
continue;
try
{
if (geometryKey(stored) == key)
return match;
}
catch (InvalidDataException)
{
// An unreadable stored package is not the same geometry; revising replaces it.
}
}
return null;
}
/// <summary>The library changes <see cref="ApplyAsync"/> makes, one line each, for the operator to confirm.</summary>
public string DescribeWrites()
{
var lines = new List<string>();
var created = Entries.Where(e => e.Action == DrawingSetImportAction.Create).ToList();
var revised = Entries.Where(e => e.Action == DrawingSetImportAction.Revise).ToList();
if (created.Count > 0)
{
lines.Add($"{created.Count} new library drawing{(created.Count == 1 ? "" : "s")}:");
lines.AddRange(created.Select(e => "- " + e.Item.Label));
}
if (revised.Count > 0)
{
if (lines.Count > 0)
lines.Add("");
lines.Add($"{revised.Count} new revision{(revised.Count == 1 ? "" : "s")} of library drawings whose geometry changed:");
lines.AddRange(revised.Select(e => $"- {e.Item.Label}: revision {e.Existing!.Revision} to {e.Existing.Revision + 1}"));
}
return string.Join(Environment.NewLine, lines);
}
/// <summary>
/// Stores the planned uploads and revisions, in order, then adds every imported drawing
/// to <paramref name="edit"/> at its quantity. A revision is guarded by the revision it
/// was planned against and keeps that drawing's details, notes and settings. When a
/// write fails, <paramref name="edit"/> is left unchanged and a
/// <see cref="DrawingSetImportException"/> lists what was stored before it.
/// </summary>
public async Task<IReadOnlyList<DrawingLibraryRecord>> ApplyAsync(IDrawingLibraryStore library,
DrawingSetEditSession edit, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(library);
ArgumentNullException.ThrowIfNull(edit);
var written = new List<DrawingLibraryRecord>();
var members = new List<(DrawingLibraryRecord Record, int Quantity)>();
foreach (var entry in Imported)
{
DrawingLibraryRecord record;
try
{
record = entry.Action switch
{
DrawingSetImportAction.Create => await library.UploadAsync(Created(entry), entry.Item.Package, cancellationToken),
DrawingSetImportAction.Revise => await library.UpdatePackageAsync(entry.Existing!.Id,
Revised(entry.Existing, entry.Item.Metadata.SavedBy), entry.Item.Package, entry.Existing.Revision, cancellationToken),
_ => entry.Existing!,
};
}
catch (Exception ex) when (ex is IOException or HttpRequestException or TaskCanceledException)
{
var stored = written.Count == 0
? "Nothing was stored in the library."
: $"Already stored: {string.Join(", ", written.Select(r => r.Name))}. Importing again reuses them.";
throw new DrawingSetImportException(
$"Could not store '{entry.Item.Label}' in the drawing library: {ex.Message}{Environment.NewLine}{stored}",
written, ex);
}
var expectedId = entry.Action == DrawingSetImportAction.Create ? entry.NewId : entry.Existing!.Id;
if (record.Id != expectedId)
throw new DrawingSetImportException(
$"The nest server returned a different library drawing than '{entry.Item.Label}'. Import again to check what was stored.",
written, new IOException("Unexpected drawing id."));
if (entry.Action != DrawingSetImportAction.Reuse)
written.Add(record);
members.Add((record, (int)entry.Item.Quantity));
}
foreach (var (record, quantity) in members)
edit.AddDrawings(new[] { record }, quantity);
return written;
}
private static DrawingLibraryRecord Created(DrawingSetImportEntry entry)
{
var record = Copy(entry.Item.Metadata);
record.Id = entry.NewId;
record.Revision = 0;
record.Thickness = DrawingLibraryRules.NormalizeThickness(record.Thickness);
record.Archived = false;
return record;
}
// The identity already matches; keep the stored spelling, notes and settings.
private static DrawingLibraryRecord Revised(DrawingLibraryRecord existing, string savedBy)
{
var record = Copy(existing);
record.Revision = 0;
record.SavedBy = savedBy;
return record;
}
private static DrawingLibraryRecord Copy(DrawingLibraryRecord record) => new()
{
Id = record.Id,
Revision = record.Revision,
Name = record.Name,
Customer = record.Customer,
MaterialName = record.MaterialName,
MaterialGrade = record.MaterialGrade,
Thickness = record.Thickness,
Units = record.Units,
CutType = record.CutType,
Notes = record.Notes,
Settings = record.Settings,
Archived = record.Archived,
SavedBy = record.SavedBy,
};
private static string Sha256(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
}
+14
View File
@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace OpenNest.IO.Bom;
@@ -41,4 +43,16 @@ public static class BomImportRows
return rows;
}
/// <summary>
/// The job a BOM file is for: its file name without the extension or a trailing
/// " BOM" ("Frame A BOM.xlsx" is "Frame A").
/// </summary>
public static string JobName(string bomPath)
{
var name = Path.GetFileNameWithoutExtension(bomPath ?? "");
if (name.EndsWith(" BOM", StringComparison.OrdinalIgnoreCase))
name = name.Substring(0, name.Length - 4).TrimEnd();
return name;
}
}
+192
View File
@@ -0,0 +1,192 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace OpenNest.IO.Bom;
/// <summary>
/// One drawing a BOM puts in a drawing set: a drawing file with one material and
/// thickness, its BOM rows' total quantity and its canonical library package.
/// </summary>
public sealed class BomSetDrawing
{
internal BomSetDrawing(
string name,
string material,
double thickness,
long quantity,
string dxfPath,
IReadOnlyList<BomPartRow> rows,
Drawing drawing,
byte[] package
)
{
Name = name;
Material = material;
Thickness = thickness;
Quantity = quantity;
DxfPath = dxfPath;
Rows = rows;
Drawing = drawing;
Package = package;
}
/// <summary>The drawing file's name without its extension.</summary>
public string Name { get; }
/// <summary>The BOM material, trimmed; blank when the BOM has none.</summary>
public string Material { get; }
/// <summary>The BOM thickness, or 0 when the BOM has none.</summary>
public double Thickness { get; }
/// <summary>The total quantity of the rows that name this drawing.</summary>
public long Quantity { get; }
public string DxfPath { get; }
/// <summary>The BOM rows this drawing came from, in BOM order.</summary>
public IReadOnlyList<BomPartRow> Rows { get; }
public Drawing Drawing { get; }
/// <summary>The drawing as a canonical <see cref="DrawingPackage"/>.</summary>
public byte[] Package { get; }
/// <summary>True when the drawing still needs a material or thickness before it can be released.</summary>
public bool NeedsDetails => Material.Length == 0 || Thickness <= 0;
/// <summary>"PT01 (items 3, 7)", or just the name when the rows have no item numbers.</summary>
public string Label
{
get
{
var items = Rows.Where(r => r.ItemNum != null).Select(r => r.ItemNum.Value.ToString()).ToList();
return items.Count == 0
? Name
: $"{Name} (item{(items.Count == 1 ? "" : "s")} {string.Join(", ", items)})";
}
}
}
/// <summary>A BOM row that does not reach the set, and why.</summary>
public sealed record BomSetSkip(BomPartRow Row, string Name, string Reason);
/// <summary>
/// The drawings a BOM puts in a drawing set and the rows it leaves out. Unlike
/// Create Nests, rows with no material or thickness are kept: the set editor and
/// Edit Drawing fix them before release.
/// </summary>
public sealed class BomSetDrawings
{
private BomSetDrawings(IReadOnlyList<BomSetDrawing> drawings, IReadOnlyList<BomSetSkip> skipped)
{
Drawings = drawings;
Skipped = skipped;
}
/// <summary>The drawings in the order their first row appears in the BOM.</summary>
public IReadOnlyList<BomSetDrawing> Drawings { get; }
public IReadOnlyList<BomSetSkip> Skipped { get; }
/// <summary>
/// Imports each drawing file named by <paramref name="rows"/> once and packages it in
/// <paramref name="units"/>. Rows naming the same file with the same material
/// (ignoring case) and thickness become one drawing needing their total quantity.
/// Rows with no file name, no drawing file or a quantity below 1, and files that
/// fail to import, are skipped with a reason.
/// </summary>
public static BomSetDrawings Build(IEnumerable<BomPartRow> rows, Units units)
{
ArgumentNullException.ThrowIfNull(rows);
var skipped = new List<BomSetSkip>();
var kept = new List<BomPartRow>();
foreach (var row in rows)
{
switch (row.Status)
{
case BomRowStatus.NoFileName:
skipped.Add(new BomSetSkip(row, RowName(row), "no file name"));
break;
case BomRowStatus.NoDrawing:
skipped.Add(new BomSetSkip(row, RowName(row), "no drawing file found"));
break;
case BomRowStatus.NeedsQuantity:
skipped.Add(new BomSetSkip(row, RowName(row), "quantity is below 1"));
break;
default:
kept.Add(row);
break;
}
}
var drawings = new List<BomSetDrawing>();
var imported = new Dictionary<string, (Drawing Drawing, byte[] Package, string Error)>(
StringComparer.OrdinalIgnoreCase
);
var groups = kept.GroupBy(r => (
Path: r.DxfPath,
Material: (r.Material ?? "").Trim().ToUpperInvariant(),
Thickness: ThicknessOf(r)
));
foreach (var group in groups)
{
var first = group.First();
if (!imported.TryGetValue(first.DxfPath, out var file))
{
file = Import(first.DxfPath, units);
imported[first.DxfPath] = file;
}
if (file.Error != null)
{
skipped.AddRange(group.Select(r => new BomSetSkip(r, RowName(r), file.Error)));
continue;
}
drawings.Add(
new BomSetDrawing(
Path.GetFileNameWithoutExtension(first.DxfPath),
(first.Material ?? "").Trim(),
group.Key.Thickness,
group.Sum(r => (long)(r.Qty ?? 1)),
first.DxfPath,
group.ToList(),
file.Drawing,
file.Package
)
);
}
return new BomSetDrawings(drawings, skipped);
}
private static (Drawing, byte[], string) Import(string path, Units units)
{
if (!File.Exists(path))
return (null, null, "drawing file not found");
try
{
var drawing = CadImporter.ImportDrawing(path);
return (drawing, DrawingPackage.Write(drawing, units), null);
}
catch (Exception ex)
{
return (null, null, $"could not import the drawing: {ex.Message}");
}
}
private static double ThicknessOf(BomPartRow row) =>
row.Thickness is double t && double.IsFinite(t) && t > 0 ? t : 0;
private static string RowName(BomPartRow row)
{
if (!string.IsNullOrWhiteSpace(row.FileName))
return row.FileName.Trim();
if (row.ItemNum != null)
return $"Item {row.ItemNum}";
return string.IsNullOrWhiteSpace(row.Description) ? "(unnamed row)" : row.Description.Trim();
}
}
+49
View File
@@ -3,9 +3,11 @@ using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace OpenNest.IO
{
@@ -70,6 +72,53 @@ namespace OpenNest.IO
}
}
/// <summary>
/// A key that is equal for packages holding the same geometry: the SHA-256 of the
/// package's entries with the source file path left out and every GUID (entity ids
/// and the references to them) replaced by its order of first appearance. An unchanged
/// DXF now imports with the same entity ids, but from another folder it records another
/// path, and packages saved before stable ids carry random ones, so their hashes differ
/// while this key does not.
/// </summary>
public static string GeometryKey(byte[] package)
{
ArgumentNullException.ThrowIfNull(package);
var text = new StringBuilder();
try
{
using var archive = new ZipArchive(new MemoryStream(package, writable: false), ZipArchiveMode.Read);
foreach (var entry in archive.Entries.OrderBy(e => e.FullName, StringComparer.Ordinal))
{
var content = ReadText(entry);
if (entry.FullName == "nest.json" && JsonNode.Parse(content) is JsonObject root)
{
foreach (var drawing in (root["drawings"] as JsonArray ?? new JsonArray()).OfType<JsonObject>())
(drawing["source"] as JsonObject)?.Remove("path");
content = root.ToJsonString();
}
text.Append(entry.FullName).Append('\n').Append(content).Append('\n');
}
}
catch (Exception ex) when (ex is JsonException || (ex is IOException && ex is not InvalidDataException))
{
throw new InvalidDataException("The drawing package is not a readable .nest archive.", ex);
}
var ids = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var normalized = GuidPattern.Replace(text.ToString(), match =>
{
if (!ids.TryGetValue(match.Value, out var index))
ids[match.Value] = index = ids.Count;
return "#" + index;
});
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant();
}
private static readonly Regex GuidPattern = new(
"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
// Checks the archive's own declaration before the lenient nest reader sees it: that
// reader defaults unknown units and drops empty plates, which a package must not hide.
private static Units CheckShape(byte[] package)
+272
View File
@@ -0,0 +1,272 @@
using OpenNest.Converters;
using OpenNest.Data;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.IO.Bom;
using OpenNest.Tests.Data;
namespace OpenNest.Tests.Bom;
/// <summary>
/// BOM rows to library drawings to a prefilled set: the drawings a BOM puts in a set,
/// and how each one is matched to the library (reuse, new revision, new drawing).
/// </summary>
public sealed class BomSetImportTests : IDisposable
{
private readonly string folder = Path.Combine(Path.GetTempPath(), "bom-set-" + Guid.NewGuid().ToString("N"));
public BomSetImportTests()
{
Directory.CreateDirectory(folder);
}
public void Dispose()
{
if (Directory.Exists(folder))
Directory.Delete(folder, recursive: true);
}
[Fact]
public void Build_SumsRowsOfOneDrawing_KeepsRowsWithoutDetails_AndSkipsTheRestWithAReason()
{
WriteSquare("PT01", 2);
WriteSquare("PT02", 3);
WriteSquare("PT03", 4);
File.WriteAllText(Path.Combine(folder, "BAD.dxf"), "not a drawing");
var rows = BomImportRows.Build(new List<BomItem>
{
Item(1, "PT01", 2, "Steel", 0.25),
Item(2, "PT02", 3, null, null),
Item(3, "pt01.dxf", 5, " steel ", 0.25),
Item(4, "PT01", 1, "Steel", 0.5),
Item(5, "MISSING", 1, "Steel", 0.25),
Item(6, null, 1, "Steel", 0.25),
Item(7, "PT03", 0, "Steel", 0.25),
Item(8, "BAD", 1, "Steel", 0.25),
}, folder);
var result = BomSetDrawings.Build(rows, Units.Inches);
Assert.Equal(new[] { ("PT01", "Steel", 0.25, 7L), ("PT02", "", 0.0, 3L), ("PT01", "Steel", 0.5, 1L) },
result.Drawings.Select(d => (d.Name, d.Material, d.Thickness, d.Quantity)));
Assert.Equal("PT01 (items 1, 3)", result.Drawings[0].Label);
Assert.True(result.Drawings[1].NeedsDetails);
Assert.False(result.Drawings[0].NeedsDetails);
Assert.Equal(new[] { ("MISSING", "no drawing file found"), ("Item 6", "no file name"), ("PT03", "quantity is below 1") },
result.Skipped.Take(3).Select(s => (s.Name, s.Reason)));
var bad = result.Skipped[3];
Assert.Equal("BAD", bad.Name);
Assert.StartsWith("could not import the drawing", bad.Reason);
// One file with two thicknesses is imported once.
Assert.Same(result.Drawings[0].Drawing, result.Drawings[2].Drawing);
}
[Fact]
public void GeometryKey_IgnoresTheFolder_ButNotGeometryOrUnits()
{
WriteSquare("PT01", 2);
var rows = BomImportRows.Build(new List<BomItem> { Item(1, "PT01", 1, "Steel", 0.25) }, folder);
var first = BomSetDrawings.Build(rows, Units.Inches).Drawings.Single().Package;
var second = BomSetDrawings.Build(rows, Units.Inches).Drawings.Single().Package;
var moved = Path.Combine(folder, "moved");
Directory.CreateDirectory(moved);
File.Copy(Path.Combine(folder, "PT01.dxf"), Path.Combine(moved, "PT01.dxf"));
var elsewhere = BomSetDrawings.Build(BomImportRows.Build(
new List<BomItem> { Item(1, "PT01", 1, "Steel", 0.25) }, moved), Units.Inches).Drawings.Single().Package;
// An unchanged file imports with the same entity ids, so the same folder gives the
// same package; another folder records another source path, so only the key matches.
Assert.Equal(first, second);
Assert.NotEqual(first, elsewhere);
Assert.Equal(DrawingPackage.GeometryKey(first), DrawingPackage.GeometryKey(elsewhere));
Assert.NotEqual(DrawingPackage.GeometryKey(first), DrawingPackage.GeometryKey(Square(3)));
Assert.NotEqual(DrawingPackage.GeometryKey(first),
DrawingPackage.GeometryKey(BomSetDrawings.Build(rows, Units.Millimeters).Drawings.Single().Package));
}
[Fact]
public async Task Plan_ReusesALibraryDrawingImportedFromTheSameFileBefore()
{
var earlier = Drawings(("PT01", 2)).Single();
var library = new InMemoryDrawingLibrary();
var stored = library.Seed("PT01", "Steel", 0.25, earlier.Package, customer: "Synthetic Co");
var plan = await DrawingSetImport.PlanAsync(library, Items(Drawings(("PT01", 2))), DrawingPackage.GeometryKey);
var entry = Assert.Single(plan.Entries);
Assert.Equal(DrawingSetImportAction.Reuse, entry.Action);
Assert.Same(stored, entry.Existing);
Assert.Equal(new[] { (stored.Id, 1) }, library.Downloads);
Assert.False(plan.HasWrites);
}
[Fact]
public async Task Plan_ReusesSameGeometry_RevisesChangedGeometry_AndCreatesTheRest_WithoutWriting()
{
var drawings = Drawings(("SAME", 2), ("CHANGED", 3), ("NEW", 4), ("OTHER-CUSTOMER", 5), ("ARCHIVED", 6));
var library = new InMemoryDrawingLibrary();
var same = library.Seed("SAME", "Steel", 0.25, drawings[0].Package, customer: "Synthetic Co");
var changed = library.Seed("changed", "STEEL", 0.25, Square(9), customer: "synthetic co");
library.Seed("OTHER-CUSTOMER", "Steel", 0.25, drawings[3].Package, customer: "Someone Else");
library.Seed("ARCHIVED", "Steel", 0.25, drawings[4].Package, customer: "Synthetic Co", archived: true);
var plan = await DrawingSetImport.PlanAsync(library, Items(drawings), DrawingPackage.GeometryKey);
Assert.Equal(new[]
{
DrawingSetImportAction.Reuse, DrawingSetImportAction.Revise, DrawingSetImportAction.Create,
DrawingSetImportAction.Create, DrawingSetImportAction.Create,
}, plan.Entries.Select(e => e.Action));
Assert.Same(same, plan.Entries[0].Existing);
Assert.Same(changed, plan.Entries[1].Existing);
Assert.Equal("In the library (revision 1)", plan.Entries[0].Describe());
Assert.Equal("New revision 2 (geometry changed)", plan.Entries[1].Describe());
Assert.Equal("New library drawing", plan.Entries[2].Describe());
Assert.True(plan.HasWrites);
Assert.Equal((3, 1), (plan.Count(DrawingSetImportAction.Create), plan.Count(DrawingSetImportAction.Revise)));
Assert.Contains("- CHANGED (item 2): revision 1 to 2", plan.DescribeWrites());
Assert.Empty(library.Writes);
}
[Fact]
public async Task Plan_SkipsAmbiguousMatches_TwinsAndQuantitiesOutOfRange()
{
var drawings = Drawings(("AMBIGUOUS", 2), ("TWIN", 3));
var library = new InMemoryDrawingLibrary();
library.Seed("AMBIGUOUS", "Steel", 0.25, Square(7), customer: "Synthetic Co");
library.Seed("AMBIGUOUS", "Steel", 0.25, Square(8), customer: "Synthetic Co");
var items = Items(drawings);
items.Add(new DrawingSetImportItem("twin again", Metadata(drawings[1]), drawings[1].Package, 1));
items.Add(new DrawingSetImportItem("too many", Metadata(drawings[0], name: "BIG"), drawings[0].Package,
DrawingSetRules.MaxQuantity + 1L));
var plan = await DrawingSetImport.PlanAsync(library, items, DrawingPackage.GeometryKey);
Assert.Equal(new[]
{
DrawingSetImportAction.Skip, DrawingSetImportAction.Create, DrawingSetImportAction.Skip,
DrawingSetImportAction.Skip,
}, plan.Entries.Select(e => e.Action));
Assert.Contains("2 library drawings have these details", plan.Entries[0].Reason);
Assert.Contains("same name and details as TWIN (item 2)", plan.Entries[2].Reason);
Assert.Contains("quantity", plan.Entries[3].Reason);
}
[Fact]
public async Task Apply_WritesOnlyNewDrawingsAndRevisions_AndPrefillsTheSetWithBomQuantities()
{
var drawings = Drawings(("SAME", 2), ("CHANGED", 3), ("NEW", 4));
var library = new InMemoryDrawingLibrary();
var same = library.Seed("SAME", "Steel", 0.25, drawings[0].Package, customer: "Synthetic Co");
var changed = library.Seed("CHANGED", "Steel", 0.25, Square(9), customer: "Synthetic Co");
var sets = new InMemoryDrawingSets();
var edit = new DrawingSetEditSession(sets, null, "synthetic");
var plan = await DrawingSetImport.PlanAsync(library, Items(drawings, quantities: new[] { 2L, 6L, 1L }),
DrawingPackage.GeometryKey);
var written = await plan.ApplyAsync(library, edit);
Assert.Equal(new[] { ("update", changed.Id), ("upload", plan.Entries[2].NewId) }, library.Writes);
Assert.Equal(new[] { changed.Id, plan.Entries[2].NewId }, written.Select(r => r.Id));
var revised = library.Get(changed.Id);
Assert.Equal((2, "Seeded notes", "synthetic operator"), (revised.Revision, revised.Notes, revised.SavedBy));
Assert.Equal(InMemoryDrawingLibrary.Sha(drawings[1].Package), revised.PackageSha256);
var created = library.Get(plan.Entries[2].NewId);
Assert.Equal(("NEW", "Synthetic Co", "Steel", 0.25), (created.Name, created.Customer, created.MaterialName, created.Thickness));
Assert.Equal(new[] { (same.Id, 2), (changed.Id, 6), (created.Id, 1) },
edit.Members.Select(m => (m.DrawingId, m.Quantity)));
Assert.Empty(sets.Creates);
}
[Fact]
public async Task Apply_StopsAtAFailedWrite_LeavesTheSetUntouched_AndImportingAgainReusesWhatWasStored()
{
var drawings = Drawings(("FIRST", 2), ("SECOND", 3));
var library = new InMemoryDrawingLibrary { FailWrite = 1 };
var edit = new DrawingSetEditSession(new InMemoryDrawingSets(), null, "synthetic");
var plan = await DrawingSetImport.PlanAsync(library, Items(drawings), DrawingPackage.GeometryKey);
var failure = await Assert.ThrowsAsync<DrawingSetImportException>(() => plan.ApplyAsync(library, edit));
Assert.Equal("FIRST", Assert.Single(failure.Written).Name);
Assert.Contains("Already stored: FIRST", failure.Message);
Assert.Empty(edit.Members);
Assert.Single(library.Current);
library.FailWrite = null;
var again = await DrawingSetImport.PlanAsync(library, Items(drawings), DrawingPackage.GeometryKey);
Assert.Equal(new[] { DrawingSetImportAction.Reuse, DrawingSetImportAction.Create }, again.Entries.Select(e => e.Action));
}
[Fact]
public async Task Plan_DrawingsWithoutMaterialOrThickness_StillImport()
{
WriteSquare("BARE", 2);
var rows = BomImportRows.Build(new List<BomItem> { Item(1, "BARE", 4, null, null) }, folder);
var library = new InMemoryDrawingLibrary();
var edit = new DrawingSetEditSession(new InMemoryDrawingSets(), null, "synthetic");
var plan = await DrawingSetImport.PlanAsync(library, Items(BomSetDrawings.Build(rows, Units.Inches).Drawings),
DrawingPackage.GeometryKey);
await plan.ApplyAsync(library, edit);
var member = Assert.Single(edit.Members);
Assert.Equal(("", 0.0, 4), (member.Drawing!.MaterialName, member.Drawing.Thickness, member.Quantity));
}
private IReadOnlyList<BomSetDrawing> Drawings(params (string Name, double Size)[] files)
{
var items = new List<BomItem>();
foreach (var (name, size) in files)
{
WriteSquare(name, size);
items.Add(Item(items.Count + 1, name, 1, "Steel", 0.25));
}
return BomSetDrawings.Build(BomImportRows.Build(items, folder), Units.Inches).Drawings;
}
// What the desktop's Import BOM dialog sends for each drawing.
private static List<DrawingSetImportItem> Items(IEnumerable<BomSetDrawing> drawings, long[]? quantities = null) =>
drawings.Select((d, i) => new DrawingSetImportItem(d.Label, Metadata(d), d.Package, quantities?[i] ?? d.Quantity))
.ToList();
private static DrawingLibraryRecord Metadata(BomSetDrawing drawing, string? name = null) => new()
{
Name = name ?? drawing.Name,
Customer = "Synthetic Co",
MaterialName = drawing.Material,
Thickness = drawing.Thickness,
Units = Units.Inches,
CutType = CutType.Laser,
Settings = DrawingNestingSettings.FromDrawing(drawing.Drawing),
SavedBy = "synthetic operator",
};
private static BomItem Item(int itemNum, string? fileName, int? qty, string? material, double? thickness) => new()
{
ItemNum = itemNum,
FileName = fileName,
Qty = qty,
Material = material,
Thickness = thickness,
};
private byte[] Square(double size)
{
var name = "SQUARE-" + size;
WriteSquare(name, size);
var rows = BomImportRows.Build(new List<BomItem> { Item(1, name, 1, "Steel", 0.25) }, folder);
return BomSetDrawings.Build(rows, Units.Inches).Drawings.Single().Package;
}
private void WriteSquare(string name, double size)
{
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(size, 0)));
shape.Entities.Add(new Line(new Vector(size, 0), new Vector(size, size)));
shape.Entities.Add(new Line(new Vector(size, size), new Vector(0, size)));
shape.Entities.Add(new Line(new Vector(0, size), new Vector(0, 0)));
Dxf.ExportProgram(ConvertGeometry.ToProgram(shape), Path.Combine(folder, name + ".dxf"));
}
}
@@ -0,0 +1,161 @@
using System.Security.Cryptography;
using OpenNest.Data;
namespace OpenNest.Tests.Data;
/// <summary>
/// An in-memory drawing library server: revisions, If-Match conflicts, archive, the
/// query filters the server applies, a write log and a switch that fails the next write.
/// Linked into OpenNest.WinForms.Tests as well.
/// </summary>
internal sealed class InMemoryDrawingLibrary : IDrawingLibraryStore
{
private readonly Dictionary<Guid, List<DrawingLibraryRecord>> _revisions = new();
private readonly Dictionary<string, byte[]> _packages = new();
private DateTime _clock = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
/// <summary>Fail the write with this index (0 is the next write) without storing it.</summary>
public int? FailWrite { get; set; }
/// <summary>Every write requested, in order: ("upload" | "update", id).</summary>
public List<(string Kind, Guid Id)> Writes { get; } = new();
public IEnumerable<DrawingLibraryRecord> Current => _revisions.Values.Select(r => r[^1]);
public DrawingLibraryRecord Get(Guid id) => _revisions[id][^1];
/// <summary>Stores revision 1 directly, as though another operator had saved it.</summary>
public DrawingLibraryRecord Seed(string name, string material, double thickness, byte[] package,
string customer = "", Units units = Units.Inches, CutType cutType = CutType.Laser, bool archived = false)
{
var id = Guid.NewGuid();
var record = Stored(new DrawingLibraryRecord
{
Name = name,
Customer = customer,
MaterialName = material,
Thickness = thickness,
Units = units,
CutType = cutType,
Notes = "Seeded notes",
}, id, 1, package);
record.Archived = archived;
_revisions[id] = new List<DrawingLibraryRecord> { record };
return record;
}
public Task<DrawingLibraryPage> QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default)
{
var search = query.NormalizedSearch;
var matches = Current
.Where(r => query.IncludeArchived || !r.Archived)
.Where(r => search.Length == 0 || new[] { r.Name, r.Customer, r.Notes }
.Any(text => text.Contains(search, StringComparison.OrdinalIgnoreCase)))
.Where(r => Filter(query.Customer, r.Customer) && Filter(query.MaterialName, r.MaterialName)
&& Filter(query.MaterialGrade, r.MaterialGrade))
.Where(r => query.Thickness is not { } t || r.Thickness == DrawingLibraryRules.NormalizeThickness(t))
.Where(r => query.CutType is not { } c || r.CutType == c)
.OrderByDescending(r => r.SavedAt)
.ToList();
return Task.FromResult(new DrawingLibraryPage
{
Items = matches.Skip(query.Offset).Take(query.Limit).ToList(),
Total = matches.Count,
Offset = query.Offset,
Limit = query.Limit,
});
}
/// <summary>Every package download requested: (id, revision).</summary>
public List<(Guid Id, int Revision)> Downloads { get; } = new();
public Task<byte[]?> GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default)
{
Downloads.Add((id, revision));
var record = _revisions.TryGetValue(id, out var list) ? list.FirstOrDefault(r => r.Revision == revision) : null;
return Task.FromResult(record != null && _packages.TryGetValue(record.PackageSha256, out var package)
? package.ToArray()
: null);
}
public Task<DrawingLibraryRecord?> GetAsync(Guid id, CancellationToken cancellationToken = default) =>
Task.FromResult(_revisions.TryGetValue(id, out var list) ? list[^1] : null);
public Task<DrawingLibraryPage?> GetRevisionsAsync(Guid id, int offset = 0,
int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default)
{
if (!_revisions.TryGetValue(id, out var list))
return Task.FromResult<DrawingLibraryPage?>(null);
var newestFirst = Enumerable.Reverse(list).ToList();
return Task.FromResult<DrawingLibraryPage?>(new DrawingLibraryPage
{
Items = newestFirst.Skip(offset).Take(limit).ToList(),
Total = list.Count,
Offset = offset,
Limit = limit,
});
}
public Task<DrawingLibraryRecord> UploadAsync(DrawingLibraryRecord record, byte[] package,
CancellationToken cancellationToken = default)
{
Write("upload", record.Id);
if (_revisions.TryGetValue(record.Id, out var existing))
throw new DrawingLibraryConflictException(existing[^1]);
_revisions[record.Id] = new List<DrawingLibraryRecord> { Stored(record, record.Id, 1, package) };
return Task.FromResult(Get(record.Id));
}
public Task<DrawingLibraryRecord> UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package,
int expectedRevision, CancellationToken cancellationToken = default)
{
Write("update", id);
var current = Get(id);
if (current.Revision != expectedRevision || current.Archived)
throw new DrawingLibraryConflictException(current);
var next = Stored(record, id, current.Revision + 1, package);
_revisions[id].Add(next);
return Task.FromResult(next);
}
public Task<DrawingLibraryRecord> UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision,
CancellationToken cancellationToken = default) =>
throw new NotSupportedException();
public static string Sha(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
private void Write(string kind, Guid id)
{
var index = Writes.Count;
Writes.Add((kind, id));
if (FailWrite == index)
throw new HttpRequestException("Synthetic connection failure.");
}
private DrawingLibraryRecord Stored(DrawingLibraryRecord metadata, Guid id, int revision, byte[] package)
{
_clock = _clock.AddMinutes(1);
_packages[Sha(package)] = package.ToArray();
return new DrawingLibraryRecord
{
Id = id,
Revision = revision,
Name = metadata.Name,
Customer = metadata.Customer,
MaterialName = metadata.MaterialName,
MaterialGrade = metadata.MaterialGrade,
Thickness = DrawingLibraryRules.NormalizeThickness(metadata.Thickness),
Units = metadata.Units,
CutType = metadata.CutType,
Notes = metadata.Notes,
Settings = metadata.Settings,
SavedBy = metadata.SavedBy,
PackageSha256 = Sha(package),
PackageSize = package.Length,
SavedAt = _clock,
};
}
private static bool Filter(string filter, string value) =>
string.IsNullOrWhiteSpace(filter) || SharedListNames.Key(filter) == SharedListNames.Key(value);
}
@@ -340,13 +340,14 @@ public class DrawingSetFormsTests
Assert.Equal(3, sets.Current.Single().Revision);
PumpUntil(() => archive.Text == "&Archive", "the restored set's menu");
// Nothing selected: only New Set stays enabled.
// Nothing selected: only New Set and More's Import BOM stay enabled.
Field<CheckBox>(browser, "showArchivedCheckBox").Checked = false;
PumpUntil(() => grid.Rows.Count == 1, "the active set");
grid.CurrentCell = null;
Assert.True(Field<Button>(browser, "newButton").Enabled);
Assert.False(Field<Button>(browser, "editButton").Enabled);
Assert.False(more.Enabled);
Assert.True(more.Enabled);
Assert.True(Field<ToolStripMenuItem>(browser, "importBomMenuItem").Enabled);
Assert.False(archive.Enabled);
Assert.False(history.Enabled);
},
@@ -0,0 +1,221 @@
using System.Reflection;
using System.Windows.Forms;
using OpenNest.Converters;
using OpenNest.Data;
using OpenNest.Forms;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.IO.Bom;
using OpenNest.Tests.Data;
using DrawingSetBrowser = OpenNest.Controls.DrawingSetBrowser;
namespace OpenNest.WinForms.Tests.Forms;
/// <summary>Drawing Sets &gt; More &gt; Import BOM: a BOM becomes a prefilled set, and nothing is stored unasked.</summary>
public sealed class ImportBomToSetFormTests : IDisposable
{
private const string Server = "http://library.test:8090";
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(60);
private readonly string folder = Path.Combine(Path.GetTempPath(), "bom-to-set-" + Guid.NewGuid().ToString("N"));
public ImportBomToSetFormTests()
{
Directory.CreateDirectory(folder);
WriteSquare("PT01", 2);
WriteSquare("PT02", 3);
}
public void Dispose()
{
if (Directory.Exists(folder))
Directory.Delete(folder, recursive: true);
}
[Fact]
public void ImportBom_OpensTheEditorPrefilled_AndCancellingItStoresNoSet()
{
StaTestThread.Run(
() =>
{
var library = new InMemoryDrawingLibrary();
// PT01 was imported from this file before; the import reuses it as it is.
var existing = library.Seed("PT01", "Steel", 0.25, Package("PT01"), customer: "Synthetic Co");
var sets = new InMemoryDrawingSets();
var confirmations = new List<string>();
var editorChecked = false;
using var form = new DrawingSetsForm(sets, library, Server);
var browser = form.Browser;
browser.ShowMessage = (text, _, _) => throw new Xunit.Sdk.XunitException(text);
browser.ImportBom = () =>
{
using var import = new ImportBomToSetForm(sets, library, Units.Inches, CutType.Laser, "synthetic operator");
import.ShowMessage = (text, _, _) =>
{
confirmations.Add(text);
return DialogResult.OK;
};
import.Show();
Field<TextBox>(import, "customerBox").Text = "Synthetic Co";
Field<TextBox>(import, "nameBox").Text = "Job 7";
Run(import.AnalyzeAsync(Items(), folder, "Job 7 BOM.xlsx"));
var grid = Field<DataGridView>(import, "drawingGrid");
Assert.Equal(
new[] { "In the library (revision 1)", "New library drawing", "Not imported: no drawing file found" },
grid.Rows.Cast<DataGridViewRow>().Select(r => r.Cells["libraryColumn"].Value));
Assert.Contains("no material or thickness", Field<Label>(import, "summaryLabel").Text);
Assert.Empty(library.Writes);
Assert.True(Run(import.CreateSetAsync()));
return import.Edit;
};
browser.EditSet = edit =>
{
using var editor = new EditDrawingSetForm(edit, library, Server);
editor.ShowMessage = (_, _, _) => DialogResult.Yes;
editor.Show();
Assert.Equal("Job 7", Field<TextBox>(editor, "nameBox").Text);
Assert.Equal("Synthetic Co", Field<TextBox>(editor, "customerBox").Text);
var members = Field<DataGridView>(editor, "memberGrid");
Assert.Equal(new object[] { "PT01", "PT02" },
members.Rows.Cast<DataGridViewRow>().Select(r => r.Cells["drawingColumn"].Value));
Assert.Equal(new[] { (existing.Id, 7), (library.Writes.Single().Id, 3) },
edit.Members.Select(m => (m.DrawingId, m.Quantity)));
editorChecked = true;
editor.Close();
return null;
};
form.Show();
Run(browser.ImportBomAsync());
Assert.True(editorChecked);
// One confirmation, naming the one new drawing before it was uploaded.
Assert.Contains("- PT02 (item 2)", Assert.Single(confirmations));
Assert.Equal("upload", library.Writes.Single().Kind);
Assert.Empty(sets.Creates);
Assert.Empty(sets.Current);
Assert.Equal("The imported set was not saved.", Field<Label>(browser, "statusLabel").Text);
},
TestTimeout,
"The Import BOM to set test did not complete.");
}
[Fact]
public void DecliningTheLibraryWrites_OrCancelling_StoresNothing()
{
StaTestThread.Run(
() =>
{
var library = new InMemoryDrawingLibrary();
var sets = new InMemoryDrawingSets();
using var import = new ImportBomToSetForm(sets, library, Units.Inches, CutType.Laser, "synthetic operator");
var asked = 0;
import.ShowMessage = (_, buttons, _) =>
{
asked++;
Assert.Equal(MessageBoxButtons.OKCancel, buttons);
return DialogResult.Cancel;
};
import.Show();
Run(import.AnalyzeAsync(Items(), folder, "Job 7 BOM.xlsx"));
Assert.True(import.Plan!.HasWrites);
Assert.False(Run(import.CreateSetAsync()));
Assert.Equal(1, asked);
Assert.Null(import.Edit);
Assert.Empty(library.Writes);
Assert.Empty(library.Current);
// A changed customer makes the plan stale until the BOM is read again.
Field<TextBox>(import, "customerBox").Text = "Another customer";
Assert.Null(import.Plan);
Assert.False(Field<Button>(import, "createButton").Enabled);
Field<Button>(import, "cancelButton").PerformClick();
Assert.Equal(DialogResult.Cancel, import.DialogResult);
Assert.Empty(library.Writes);
Assert.Empty(sets.Creates);
},
TestTimeout,
"The declined Import BOM test did not complete.");
}
[Fact]
public void ImportBom_IsInTheMoreMenu_WithoutASelectedSet_AndNotInThePicker()
{
StaTestThread.Run(
() =>
{
var library = new InMemoryDrawingLibrary();
var sets = new InMemoryDrawingSets();
using var form = new DrawingSetsForm(sets, library, Server);
form.Show();
var browser = form.Browser;
PumpUntil(() => sets.Queries.Count == 1, "the set list");
PumpUntil(() => Field<Button>(browser, "moreButton").Enabled, "the More button");
var item = Field<ToolStripMenuItem>(browser, "importBomMenuItem");
Assert.Null(browser.SelectedSet);
Assert.True(item.Enabled);
Assert.Same(item, Field<ContextMenuStrip>(browser, "moreMenu").Items.Cast<ToolStripItem>().Last());
using var picker = new ChooseDrawingSetForm(sets, library, Server);
picker.Show();
var pickBrowser = Field<DrawingSetBrowser>(picker, "browser");
Assert.False(Field<ToolStripMenuItem>(pickBrowser, "importBomMenuItem").Enabled);
Assert.Null(Field<DataGridView>(pickBrowser, "setGrid").ContextMenuStrip);
},
TestTimeout,
"The Import BOM menu test did not complete.");
}
private static List<BomItem> Items() => new()
{
new BomItem { ItemNum = 1, FileName = "PT01", Qty = 3, Material = "Steel", Thickness = 0.25 },
new BomItem { ItemNum = 2, FileName = "PT02", Qty = 3 },
new BomItem { ItemNum = 3, FileName = "MISSING", Qty = 1, Material = "Steel", Thickness = 0.25 },
new BomItem { ItemNum = 4, FileName = "PT01.dxf", Qty = 4, Material = "steel", Thickness = 0.25 },
};
private byte[] Package(string name)
{
var rows = BomImportRows.Build(new List<BomItem> { new() { FileName = name, Qty = 1 } }, folder);
return BomSetDrawings.Build(rows, Units.Inches).Drawings.Single().Package;
}
private void WriteSquare(string name, double size)
{
var shape = new Shape();
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(size, 0)));
shape.Entities.Add(new Line(new Vector(size, 0), new Vector(size, size)));
shape.Entities.Add(new Line(new Vector(size, size), new Vector(0, size)));
shape.Entities.Add(new Line(new Vector(0, size), new Vector(0, 0)));
Dxf.ExportProgram(ConvertGeometry.ToProgram(shape), Path.Combine(folder, name + ".dxf"));
}
private static void Run(Task task) => PumpUntil(() => task.IsCompleted, "the operation to finish");
private static T Run<T>(Task<T> task)
{
PumpUntil(() => task.IsCompleted, "the operation to finish");
return task.GetAwaiter().GetResult();
}
private static void PumpUntil(Func<bool> condition, string what)
{
var until = DateTime.UtcNow.AddSeconds(15);
while (!condition() && DateTime.UtcNow < until)
{
Application.DoEvents();
Thread.Sleep(10);
}
Assert.True(condition(), $"Timed out waiting for {what}.");
}
private static T Field<T>(object owner, string name) =>
(T)owner.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(owner)!;
}
@@ -18,5 +18,6 @@
<ProjectReference Include="..\OpenNest.Gpu\OpenNest.Gpu.csproj" />
<Content Include="Fixtures\absolute-coordinate-parts.nest" CopyToOutputDirectory="PreserveNewest" />
<Compile Include="..\OpenNest.Tests\Data\InMemoryDrawingSets.cs" Link="Fixtures\InMemoryDrawingSets.cs" />
<Compile Include="..\OpenNest.Tests\Data\InMemoryDrawingLibrary.cs" Link="Fixtures\InMemoryDrawingLibrary.cs" />
</ItemGroup>
</Project>
+16 -1
View File
@@ -50,6 +50,8 @@ namespace OpenNest.Controls
archiveMenuItem = new System.Windows.Forms.ToolStripMenuItem();
historyMenuItem = new System.Windows.Forms.ToolStripMenuItem();
releasesMenuItem = new System.Windows.Forms.ToolStripMenuItem();
importSeparator = new System.Windows.Forms.ToolStripSeparator();
importBomMenuItem = new System.Windows.Forms.ToolStripMenuItem();
layoutTable.SuspendLayout();
filterTable.SuspendLayout();
((System.ComponentModel.ISupportInitialize)setGrid).BeginInit();
@@ -324,7 +326,7 @@ namespace OpenNest.Controls
//
// moreMenu
//
moreMenu.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { archiveMenuItem, historyMenuItem, releasesMenuItem });
moreMenu.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { archiveMenuItem, historyMenuItem, releasesMenuItem, importSeparator, importBomMenuItem });
moreMenu.Name = "moreMenu";
moreMenu.Opening += MoreMenu_Opening;
//
@@ -349,6 +351,17 @@ namespace OpenNest.Controls
releasesMenuItem.Text = "Re&leases...";
releasesMenuItem.Click += ReleasesMenuItem_Click;
//
// importSeparator
//
importSeparator.Name = "importSeparator";
//
// importBomMenuItem
//
importBomMenuItem.Enabled = false;
importBomMenuItem.Name = "importBomMenuItem";
importBomMenuItem.Text = "&Import BOM...";
importBomMenuItem.Click += ImportBomMenuItem_Click;
//
// DrawingSetBrowser
//
AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
@@ -400,5 +413,7 @@ namespace OpenNest.Controls
private System.Windows.Forms.ToolStripMenuItem archiveMenuItem;
private System.Windows.Forms.ToolStripMenuItem historyMenuItem;
private System.Windows.Forms.ToolStripMenuItem releasesMenuItem;
private System.Windows.Forms.ToolStripSeparator importSeparator;
private System.Windows.Forms.ToolStripMenuItem importBomMenuItem;
}
}
+47 -3
View File
@@ -16,8 +16,8 @@ namespace OpenNest.Controls
/// <see cref="DrawingSetBrowseSession"/>, and creates, edits, archives or restores them and
/// shows their history. <see cref="DrawingSetsForm"/> hosts it on its Sets tab to manage and
/// release sets, and shows a set's Releases on its Releases tab. New Set, Edit and Make Nests
/// are buttons; Archive/Restore, History and Releases are in the More menu, which is also the
/// list's right-click menu. In pick mode
/// are buttons; Archive/Restore, History and Releases, then Import BOM, are in the More menu,
/// which is also the list's right-click menu. In pick mode
/// (<see cref="ChooseDrawingSetForm"/>) only active sets are listed, and choosing a set or
/// New Set raises <see cref="SetActivated"/> or <see cref="NewSetChosen"/> instead.
/// </summary>
@@ -46,6 +46,12 @@ namespace OpenNest.Controls
/// <summary>Shows a set's releases; set by the host (Releases is disabled without it) and replaced by tests.</summary>
internal Action<DrawingSetRecord> ShowReleases { get; set; }
/// <summary>
/// Shows Import BOM and returns the new set it prefilled (its library drawings already
/// stored with the operator's consent), or null when cancelled; replaced by tests.
/// </summary>
internal Func<DrawingSetEditSession> ImportBom { get; set; }
/// <summary>Shows the release dialog for a set read with its members; replaced by tests.</summary>
internal Action<DrawingSetRecord> ShowRelease { get; set; }
@@ -106,6 +112,14 @@ namespace OpenNest.Controls
using var form = new DrawingSetHistoryForm(sets, set);
form.ShowDialog(FindForm());
};
ImportBom = () =>
{
var defaults = MachineSettings.LoadSavedNestDefaults(out _);
var cutType = Enum.TryParse<CutType>(Properties.Settings.Default.DrawingLibraryCutType, out var saved)
&& Enum.IsDefined(saved) ? saved : CutType.Laser;
using var form = new ImportBomToSetForm(sets, library, defaults.Units, cutType, Environment.UserName);
return form.ShowDialog(FindForm()) == DialogResult.OK ? form.Edit : null;
};
setGrid.ContextMenuStrip = pick ? null : moreMenu;
ShowRelease = set =>
{
@@ -248,6 +262,34 @@ namespace OpenNest.Controls
private async void ReleaseButton_Click(object sender, EventArgs e) => await ReleaseSelectedAsync();
private async void ImportBomMenuItem_Click(object sender, EventArgs e) => await ImportBomAsync();
/// <summary>
/// Imports a BOM into a new set: the import dialog matches its drawings to the library,
/// then the set editor opens prefilled for review and Save, as New Set does.
/// </summary>
internal async Task ImportBomAsync()
{
if (session == null || pick || busy)
return;
var edit = ImportBom();
if (edit == null || IsDisposed)
return;
var saved = EditSet(edit);
if (IsDisposed)
return;
if (saved == null)
{
statusLabel.Text = "The imported set was not saved.";
return;
}
await RunAsync(session.RefreshAsync);
if (!IsDisposed)
statusLabel.Text = $"Saved set '{saved.Name}' revision {saved.Revision}.";
}
/// <summary>Reads the selected active set with its members' current drawings and opens the release dialog.</summary>
internal async Task ReleaseSelectedAsync()
{
@@ -456,7 +498,9 @@ namespace OpenNest.Controls
archiveMenuItem.Text = selected is { Archived: true } ? "&Restore" : "&Archive";
historyMenuItem.Enabled = ready && selected != null;
releasesMenuItem.Enabled = ready && selected != null && ShowReleases != null;
moreButton.Enabled = archiveMenuItem.Enabled || historyMenuItem.Enabled || releasesMenuItem.Enabled;
importBomMenuItem.Enabled = ready && !pick;
moreButton.Enabled = archiveMenuItem.Enabled || historyMenuItem.Enabled || releasesMenuItem.Enabled
|| importBomMenuItem.Enabled;
releaseButton.Enabled = ready && nests != null && selected is { Archived: false, MemberCount: > 0 };
}
}
+1 -6
View File
@@ -62,12 +62,7 @@ namespace OpenNest.Forms
txtDxfFolder.Text = Path.GetDirectoryName(dlg.FileName);
if (string.IsNullOrWhiteSpace(txtJobName.Text))
{
var name = Path.GetFileNameWithoutExtension(dlg.FileName);
if (name.EndsWith(" BOM", StringComparison.OrdinalIgnoreCase))
name = name.Substring(0, name.Length - 4).TrimEnd();
txtJobName.Text = name;
}
txtJobName.Text = BomImportRows.JobName(dlg.FileName);
}
private void BrowseDxf_Click(object sender, EventArgs e)
+12
View File
@@ -69,10 +69,22 @@ namespace OpenNest.Forms
/// <summary>Switches to the Sets tab.</summary>
public void ShowSets() => tabs.SelectedTab = setsTab;
/// <summary>
/// Opens Import BOM on the Sets tab once the window is shown (File &gt; Import BOM in
/// Database mode).
/// </summary>
public bool ImportBomWhenShown { get; set; }
private async void DrawingSetsForm_Shown(object sender, EventArgs e)
{
shown = true;
await RefreshSelectedTabAsync();
if (ImportBomWhenShown && !IsDisposed)
{
ImportBomWhenShown = false;
ShowSets();
await browser.ImportBomAsync();
}
}
private async void Tabs_SelectedIndexChanged(object sender, EventArgs e)
+339
View File
@@ -0,0 +1,339 @@
namespace OpenNest.Forms
{
partial class ImportBomToSetForm
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
private void InitializeComponent()
{
layoutTable = new System.Windows.Forms.TableLayoutPanel();
inputTable = new System.Windows.Forms.TableLayoutPanel();
bomLabel = new System.Windows.Forms.Label();
bomBox = new System.Windows.Forms.TextBox();
browseBomButton = new System.Windows.Forms.Button();
folderLabel = new System.Windows.Forms.Label();
folderBox = new System.Windows.Forms.TextBox();
browseFolderButton = new System.Windows.Forms.Button();
nameLabel = new System.Windows.Forms.Label();
nameBox = new System.Windows.Forms.TextBox();
customerLabel = new System.Windows.Forms.Label();
customerBox = new System.Windows.Forms.TextBox();
readButton = new System.Windows.Forms.Button();
drawingGrid = new System.Windows.Forms.DataGridView();
drawingColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
materialColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
thicknessColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
quantityColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
libraryColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
noteColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
summaryLabel = new System.Windows.Forms.Label();
buttonPanel = new System.Windows.Forms.FlowLayoutPanel();
cancelButton = new System.Windows.Forms.Button();
createButton = new System.Windows.Forms.Button();
layoutTable.SuspendLayout();
inputTable.SuspendLayout();
((System.ComponentModel.ISupportInitialize)drawingGrid).BeginInit();
buttonPanel.SuspendLayout();
SuspendLayout();
//
// layoutTable
//
layoutTable.ColumnCount = 1;
layoutTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F));
layoutTable.Controls.Add(inputTable, 0, 0);
layoutTable.Controls.Add(drawingGrid, 0, 1);
layoutTable.Controls.Add(summaryLabel, 0, 2);
layoutTable.Controls.Add(buttonPanel, 0, 3);
layoutTable.Dock = System.Windows.Forms.DockStyle.Fill;
layoutTable.Location = new System.Drawing.Point(0, 0);
layoutTable.Name = "layoutTable";
layoutTable.Padding = new System.Windows.Forms.Padding(8);
layoutTable.RowCount = 4;
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F));
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
layoutTable.Size = new System.Drawing.Size(904, 561);
layoutTable.TabIndex = 0;
//
// inputTable
//
inputTable.AutoSize = true;
inputTable.ColumnCount = 5;
inputTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
inputTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 50F));
inputTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
inputTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 50F));
inputTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle());
inputTable.Controls.Add(bomLabel, 0, 0);
inputTable.Controls.Add(bomBox, 1, 0);
inputTable.Controls.Add(browseBomButton, 4, 0);
inputTable.Controls.Add(folderLabel, 0, 1);
inputTable.Controls.Add(folderBox, 1, 1);
inputTable.Controls.Add(browseFolderButton, 4, 1);
inputTable.Controls.Add(nameLabel, 0, 2);
inputTable.Controls.Add(nameBox, 1, 2);
inputTable.Controls.Add(customerLabel, 2, 2);
inputTable.Controls.Add(customerBox, 3, 2);
inputTable.Controls.Add(readButton, 4, 2);
inputTable.SetColumnSpan(bomBox, 3);
inputTable.SetColumnSpan(folderBox, 3);
inputTable.Dock = System.Windows.Forms.DockStyle.Fill;
inputTable.Margin = new System.Windows.Forms.Padding(0, 0, 0, 6);
inputTable.Name = "inputTable";
inputTable.RowCount = 3;
inputTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
inputTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
inputTable.RowStyles.Add(new System.Windows.Forms.RowStyle());
inputTable.TabIndex = 0;
//
// bomLabel
//
bomLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
bomLabel.AutoSize = true;
bomLabel.Name = "bomLabel";
bomLabel.TabIndex = 0;
bomLabel.Text = "&BOM:";
//
// bomBox
//
bomBox.Dock = System.Windows.Forms.DockStyle.Fill;
bomBox.Name = "bomBox";
bomBox.TabIndex = 1;
bomBox.TextChanged += Inputs_Changed;
//
// browseBomButton
//
browseBomButton.AutoSize = true;
browseBomButton.Name = "browseBomButton";
browseBomButton.TabIndex = 2;
browseBomButton.Text = "Bro&wse...";
browseBomButton.UseVisualStyleBackColor = true;
browseBomButton.Click += BrowseBomButton_Click;
//
// folderLabel
//
folderLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
folderLabel.AutoSize = true;
folderLabel.Name = "folderLabel";
folderLabel.TabIndex = 3;
folderLabel.Text = "&Drawing folder:";
//
// folderBox
//
folderBox.Dock = System.Windows.Forms.DockStyle.Fill;
folderBox.Name = "folderBox";
folderBox.TabIndex = 4;
folderBox.TextChanged += Inputs_Changed;
//
// browseFolderButton
//
browseFolderButton.AutoSize = true;
browseFolderButton.Name = "browseFolderButton";
browseFolderButton.TabIndex = 5;
browseFolderButton.Text = "Brow&se...";
browseFolderButton.UseVisualStyleBackColor = true;
browseFolderButton.Click += BrowseFolderButton_Click;
//
// nameLabel
//
nameLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
nameLabel.AutoSize = true;
nameLabel.Name = "nameLabel";
nameLabel.TabIndex = 6;
nameLabel.Text = "Set &name:";
//
// nameBox
//
nameBox.Dock = System.Windows.Forms.DockStyle.Fill;
nameBox.Name = "nameBox";
nameBox.TabIndex = 7;
//
// customerLabel
//
customerLabel.Anchor = System.Windows.Forms.AnchorStyles.Left;
customerLabel.AutoSize = true;
customerLabel.Margin = new System.Windows.Forms.Padding(12, 0, 3, 0);
customerLabel.Name = "customerLabel";
customerLabel.TabIndex = 8;
customerLabel.Text = "C&ustomer:";
//
// customerBox
//
customerBox.Dock = System.Windows.Forms.DockStyle.Fill;
customerBox.Name = "customerBox";
customerBox.TabIndex = 9;
customerBox.TextChanged += Inputs_Changed;
//
// readButton
//
readButton.AutoSize = true;
readButton.Name = "readButton";
readButton.TabIndex = 10;
readButton.Text = "&Read BOM";
readButton.UseVisualStyleBackColor = true;
readButton.Click += ReadButton_Click;
//
// drawingGrid
//
drawingGrid.AllowUserToAddRows = false;
drawingGrid.AllowUserToDeleteRows = false;
drawingGrid.AllowUserToResizeRows = false;
drawingGrid.AutoSizeColumnsMode = System.Windows.Forms.DataGridViewAutoSizeColumnsMode.Fill;
drawingGrid.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
drawingGrid.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { drawingColumn, materialColumn, thicknessColumn, quantityColumn, libraryColumn, noteColumn });
drawingGrid.Dock = System.Windows.Forms.DockStyle.Fill;
drawingGrid.Name = "drawingGrid";
drawingGrid.ReadOnly = true;
drawingGrid.RowHeadersVisible = false;
drawingGrid.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect;
drawingGrid.ShowCellToolTips = true;
drawingGrid.TabIndex = 1;
//
// drawingColumn
//
drawingColumn.FillWeight = 130F;
drawingColumn.HeaderText = "Drawing";
drawingColumn.Name = "drawingColumn";
drawingColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
//
// materialColumn
//
materialColumn.FillWeight = 80F;
materialColumn.HeaderText = "Material";
materialColumn.Name = "materialColumn";
materialColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
//
// thicknessColumn
//
thicknessColumn.FillWeight = 50F;
thicknessColumn.HeaderText = "Thickness";
thicknessColumn.Name = "thicknessColumn";
thicknessColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
//
// quantityColumn
//
quantityColumn.DefaultCellStyle = new System.Windows.Forms.DataGridViewCellStyle { Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleRight };
quantityColumn.FillWeight = 40F;
quantityColumn.HeaderText = "Qty";
quantityColumn.Name = "quantityColumn";
quantityColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
quantityColumn.ToolTipText = "Quantity per set: the BOM quantity, summed over rows that name the drawing";
//
// libraryColumn
//
libraryColumn.FillWeight = 120F;
libraryColumn.HeaderText = "Drawing library";
libraryColumn.Name = "libraryColumn";
libraryColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
//
// noteColumn
//
noteColumn.FillWeight = 120F;
noteColumn.HeaderText = "Note";
noteColumn.Name = "noteColumn";
noteColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable;
//
// summaryLabel
//
summaryLabel.AutoSize = true;
summaryLabel.Dock = System.Windows.Forms.DockStyle.Fill;
summaryLabel.Margin = new System.Windows.Forms.Padding(3, 6, 3, 0);
summaryLabel.Name = "summaryLabel";
summaryLabel.TabIndex = 2;
summaryLabel.Text = "Choose a BOM and the folder of its drawings, then Read BOM. Nothing is stored until you create the set.";
//
// buttonPanel
//
buttonPanel.AutoSize = true;
buttonPanel.Controls.Add(cancelButton);
buttonPanel.Controls.Add(createButton);
buttonPanel.Dock = System.Windows.Forms.DockStyle.Fill;
buttonPanel.FlowDirection = System.Windows.Forms.FlowDirection.RightToLeft;
buttonPanel.Margin = new System.Windows.Forms.Padding(0, 6, 0, 0);
buttonPanel.Name = "buttonPanel";
buttonPanel.TabIndex = 3;
//
// cancelButton
//
cancelButton.AutoSize = true;
cancelButton.DialogResult = System.Windows.Forms.DialogResult.Cancel;
cancelButton.Name = "cancelButton";
cancelButton.TabIndex = 1;
cancelButton.Text = "Cancel";
cancelButton.UseVisualStyleBackColor = true;
//
// createButton
//
createButton.AutoSize = true;
createButton.Enabled = false;
createButton.Name = "createButton";
createButton.TabIndex = 0;
createButton.Text = "&Create Set...";
createButton.UseVisualStyleBackColor = true;
createButton.Click += CreateButton_Click;
//
// ImportBomToSetForm
//
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
CancelButton = cancelButton;
ClientSize = new System.Drawing.Size(904, 561);
Controls.Add(layoutTable);
MinimizeBox = false;
MinimumSize = new System.Drawing.Size(700, 420);
Name = "ImportBomToSetForm";
ShowIcon = false;
ShowInTaskbar = false;
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
Text = "Import BOM to Set";
FormClosing += ImportBomToSetForm_FormClosing;
layoutTable.ResumeLayout(false);
layoutTable.PerformLayout();
inputTable.ResumeLayout(false);
inputTable.PerformLayout();
((System.ComponentModel.ISupportInitialize)drawingGrid).EndInit();
buttonPanel.ResumeLayout(false);
buttonPanel.PerformLayout();
ResumeLayout(false);
}
#endregion
private System.Windows.Forms.TableLayoutPanel layoutTable;
private System.Windows.Forms.TableLayoutPanel inputTable;
private System.Windows.Forms.Label bomLabel;
private System.Windows.Forms.TextBox bomBox;
private System.Windows.Forms.Button browseBomButton;
private System.Windows.Forms.Label folderLabel;
private System.Windows.Forms.TextBox folderBox;
private System.Windows.Forms.Button browseFolderButton;
private System.Windows.Forms.Label nameLabel;
private System.Windows.Forms.TextBox nameBox;
private System.Windows.Forms.Label customerLabel;
private System.Windows.Forms.TextBox customerBox;
private System.Windows.Forms.Button readButton;
private System.Windows.Forms.DataGridView drawingGrid;
private System.Windows.Forms.DataGridViewTextBoxColumn drawingColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn materialColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn thicknessColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn quantityColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn libraryColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn noteColumn;
private System.Windows.Forms.Label summaryLabel;
private System.Windows.Forms.FlowLayoutPanel buttonPanel;
private System.Windows.Forms.Button cancelButton;
private System.Windows.Forms.Button createButton;
}
}
+320
View File
@@ -0,0 +1,320 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Controls;
using OpenNest.Data;
using OpenNest.IO;
using OpenNest.IO.Bom;
namespace OpenNest.Forms
{
/// <summary>
/// Drawing Sets &gt; More &gt; Import BOM: reads a BOM and its drawing folder
/// (<see cref="BomImportRows"/>, <see cref="BomSetDrawings"/>), matches each drawing to the
/// library by its identity and geometry (<see cref="DrawingSetImport"/>) and lists what
/// that would do. Reading stores nothing. <b>Create Set</b> asks before it uploads new
/// drawings or revisions, stores them, and returns a new set prefilled with the drawings
/// and their BOM quantities in <see cref="Edit"/>; the set is stored only when the
/// operator saves it in the set editor.
/// </summary>
public partial class ImportBomToSetForm : Form
{
private const string NeedsDetailsNote = "No material or thickness: set it with Edit Drawing in the set editor before making nests.";
private readonly IDrawingSetStore sets;
private readonly IDrawingLibraryStore library;
private readonly Units units;
private readonly CutType cutType;
private readonly string savedBy;
private BomSetDrawings drawings;
private DrawingSetImport plan;
private string plannedBom = "";
private bool busy;
private bool writing;
/// <summary>Shows a message and returns the operator's answer; replaced by tests.</summary>
internal Func<string, MessageBoxButtons, MessageBoxIcon, DialogResult> ShowMessage { get; set; }
/// <param name="units">Units new library drawings are packaged in (the new-nest default).</param>
/// <param name="cutType">Cut type of new library drawings, and part of each drawing's identity.</param>
public ImportBomToSetForm(IDrawingSetStore sets, IDrawingLibraryStore library, Units units, CutType cutType, string savedBy)
{
InitializeComponent();
GridStyle.Apply(drawingGrid);
this.sets = sets ?? throw new ArgumentNullException(nameof(sets));
this.library = library ?? throw new ArgumentNullException(nameof(library));
this.units = units;
this.cutType = cutType;
this.savedBy = savedBy ?? "";
ShowMessage = (text, buttons, icon) => MessageBox.Show(this, text, Text, buttons, icon);
}
/// <summary>The matched drawings, once the BOM has been read; null before.</summary>
internal DrawingSetImport Plan => plan;
/// <summary>The new set prefilled with the imported drawings, after Create Set; null before.</summary>
public DrawingSetEditSession Edit { get; private set; }
private async void ReadButton_Click(object sender, EventArgs e)
{
var path = bomBox.Text.Trim();
if (!File.Exists(path))
{
ShowMessage("The BOM file does not exist.", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
List<BomItem> items;
try
{
items = await Task.Run(() =>
{
using var reader = new BomReader(path);
return reader.GetItems();
});
}
catch (Exception ex)
{
ShowMessage($"Could not read the BOM: {ex.Message}", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
if (IsDisposed)
return;
if (string.IsNullOrWhiteSpace(nameBox.Text))
nameBox.Text = BomImportRows.JobName(path);
await AnalyzeAsync(items, folderBox.Text.Trim(), Path.GetFileName(path));
}
/// <summary>
/// Imports and packages the BOM's drawings, then matches them to the library and
/// lists the outcome. Only reads from the library.
/// </summary>
internal async Task AnalyzeAsync(List<BomItem> items, string folder, string bomName)
{
if (busy)
return;
SetBusy(true, "Reading the drawings...");
plan = null;
drawingGrid.Rows.Clear();
var customer = customerBox.Text.Trim();
try
{
drawings = await Task.Run(() => BomSetDrawings.Build(BomImportRows.Build(items, folder), units));
if (IsDisposed)
return;
summaryLabel.Text = "Matching the drawings to the library...";
plan = await DrawingSetImport.PlanAsync(library, drawings.Drawings.Select(d => ImportItem(d, customer)),
DrawingPackage.GeometryKey);
}
catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException)
{
// ServerTooOldException is an IOException too.
if (!IsDisposed)
summaryLabel.Text = $"Could not match the drawings to the library: {ex.Message}";
return;
}
finally
{
if (!IsDisposed)
SetBusy(false, null);
}
if (IsDisposed)
return;
plannedBom = bomName ?? "";
Populate();
}
private DrawingSetImportItem ImportItem(BomSetDrawing drawing, string customer) =>
new(drawing.Label, new DrawingLibraryRecord
{
Name = drawing.Name,
Customer = customer,
MaterialName = drawing.Material,
Thickness = drawing.Thickness,
Units = units,
CutType = cutType,
Settings = DrawingNestingSettings.FromDrawing(drawing.Drawing),
SavedBy = savedBy,
}, drawing.Package, drawing.Quantity);
private void Populate()
{
drawingGrid.Rows.Clear();
for (var i = 0; i < plan.Entries.Count; i++)
{
var entry = plan.Entries[i];
var drawing = drawings.Drawings[i];
var index = drawingGrid.Rows.Add(
entry.Item.Label,
drawing.Material,
drawing.Thickness > 0 ? drawing.Thickness.ToString("0.####") : "",
drawing.Quantity,
entry.Describe(),
entry.Action != DrawingSetImportAction.Skip && drawing.NeedsDetails ? NeedsDetailsNote : "");
var row = drawingGrid.Rows[index];
row.Tag = entry;
if (entry.Action == DrawingSetImportAction.Skip)
row.DefaultCellStyle.ForeColor = Color.Gray;
else if (drawing.NeedsDetails)
row.Cells[noteColumn.Index].ToolTipText = NeedsDetailsNote;
}
foreach (var skip in drawings.Skipped)
{
var index = drawingGrid.Rows.Add(
skip.Row.ItemNum is int item ? $"{skip.Name} (item {item})" : skip.Name,
skip.Row.Material ?? "",
skip.Row.Thickness is double t ? t.ToString("0.####") : "",
skip.Row.Qty?.ToString() ?? "",
"Not imported: " + skip.Reason,
"");
drawingGrid.Rows[index].DefaultCellStyle.ForeColor = Color.Gray;
}
summaryLabel.Text = Describe(plan, drawings);
UpdateCommands();
}
/// <summary>One line on what Create Set would do and what to fix later.</summary>
internal static string Describe(DrawingSetImport plan, BomSetDrawings drawings)
{
var imported = plan.Imported.Count();
var parts = new List<string>
{
$"{imported} drawing{(imported == 1 ? "" : "s")} for the set: "
+ $"{plan.Count(DrawingSetImportAction.Reuse)} already in the library, "
+ $"{plan.Count(DrawingSetImportAction.Revise)} new revision{(plan.Count(DrawingSetImportAction.Revise) == 1 ? "" : "s")}, "
+ $"{plan.Count(DrawingSetImportAction.Create)} new.",
};
var left = plan.Count(DrawingSetImportAction.Skip) + drawings.Skipped.Count;
if (left > 0)
parts.Add($"{left} not imported (greyed).");
var bare = plan.Entries.Where((e, i) => e.Action != DrawingSetImportAction.Skip && drawings.Drawings[i].NeedsDetails).Count();
if (bare > 0)
parts.Add($"{bare} {(bare == 1 ? "has" : "have")} no material or thickness; fix {(bare == 1 ? "it" : "them")} with Edit Drawing in the set editor before making nests.");
return string.Join(" ", parts);
}
private async void CreateButton_Click(object sender, EventArgs e) => await CreateSetAsync();
/// <summary>
/// After the operator confirms any library writes, stores them and prefills
/// <see cref="Edit"/>; closes with OK. Returns false when nothing was done.
/// </summary>
internal async Task<bool> CreateSetAsync()
{
if (busy || plan == null || !plan.Imported.Any())
return false;
if (plan.HasWrites && ShowMessage(
"Store these in the drawing library now?" + Environment.NewLine + Environment.NewLine
+ plan.DescribeWrites() + Environment.NewLine + Environment.NewLine
+ "They are stored when you click OK. The set itself is stored only when you click Save in the "
+ "set editor; if you cancel there, these library drawings stay, and importing the BOM again reuses them.",
MessageBoxButtons.OKCancel, MessageBoxIcon.Question) != DialogResult.OK)
return false;
var edit = new DrawingSetEditSession(sets, null, savedBy)
{
Name = nameBox.Text.Trim(),
Customer = customerBox.Text.Trim(),
Notes = plannedBom.Length == 0 ? "" : $"Imported from BOM {plannedBom}.",
};
writing = true;
SetBusy(true, plan.HasWrites ? "Storing the drawings in the library..." : null);
try
{
await plan.ApplyAsync(library, edit);
}
catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException)
{
writing = false;
if (IsDisposed)
return false;
// The library changed part-way, so the plan no longer describes it.
plan = null;
SetBusy(false, "Read the BOM again to see what the library holds now.");
ShowMessage(ex.Message, MessageBoxButtons.OK, MessageBoxIcon.Error);
return false;
}
busy = writing = false;
if (IsDisposed)
return false;
Edit = edit;
DialogResult = DialogResult.OK;
Close();
return true;
}
private void ImportBomToSetForm_FormClosing(object sender, FormClosingEventArgs e)
{
// Library writes in flight finish first, so the operator learns what was stored.
if (writing && e.CloseReason == CloseReason.UserClosing)
e.Cancel = true;
}
private void Inputs_Changed(object sender, EventArgs e)
{
// The BOM, folder and customer decide what matches; a plan for others is stale.
if (plan == null || busy)
return;
plan = null;
summaryLabel.Text = "Read the BOM again to match the drawings with these details.";
UpdateCommands();
}
private void BrowseBomButton_Click(object sender, EventArgs e)
{
using var dialog = new OpenFileDialog
{
Title = "Select BOM File",
Filter = "Excel Files|*.xlsx|All Files|*.*",
};
if (dialog.ShowDialog(this) != DialogResult.OK)
return;
bomBox.Text = dialog.FileName;
if (string.IsNullOrWhiteSpace(folderBox.Text))
folderBox.Text = Path.GetDirectoryName(dialog.FileName);
nameBox.Text = BomImportRows.JobName(dialog.FileName);
}
private void BrowseFolderButton_Click(object sender, EventArgs e)
{
using var dialog = new FolderBrowserDialog
{
Description = "Select the folder of the BOM's drawings",
SelectedPath = folderBox.Text,
};
if (dialog.ShowDialog(this) == DialogResult.OK)
folderBox.Text = dialog.SelectedPath;
}
private void SetBusy(bool value, string status)
{
busy = value;
if (status != null)
summaryLabel.Text = status;
UseWaitCursor = value;
UpdateCommands();
}
private void UpdateCommands()
{
readButton.Enabled = !busy;
browseBomButton.Enabled = !busy;
browseFolderButton.Enabled = !busy;
cancelButton.Enabled = !writing;
createButton.Enabled = !busy && plan != null && plan.Imported.Any();
}
}
}
+16 -1
View File
@@ -89,8 +89,19 @@ namespace OpenNest.Forms
"Nest Load Warnings", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
/// <summary>
/// File mode: the BOM import dialog, which makes nests directly. Database mode: a BOM
/// becomes a drawing set instead (Drawing Sets &gt; More &gt; Import BOM), whose Make Nests
/// makes the nests.
/// </summary>
private void ImportBom_Click(object sender, EventArgs e)
{
if (NestStorage.Settings.IsDatabaseMode)
{
ShowDrawingSets(releases: false, importBom: true);
return;
}
var form = new BomImportForm();
form.MdiParentForm = this;
form.ShowDialog(this);
@@ -107,6 +118,9 @@ namespace OpenNest.Forms
mnuFileDrawingSets.ToolTipText = databaseMode ? "" : NestStorage.DatabaseModeOnly;
mnuFileReleases.Enabled = databaseMode;
mnuFileReleases.ToolTipText = databaseMode ? "" : NestStorage.DatabaseModeOnly;
mnuFileImportBom.ToolTipText = databaseMode
? "Imports the BOM into a new drawing set; Make Nests then makes its nests."
: "";
}
/// <summary>
@@ -147,7 +161,7 @@ namespace OpenNest.Forms
/// <summary>A shortcut to the Drawing Sets window's Releases tab, listing every release.</summary>
private void Releases_Click(object sender, EventArgs e) => ShowDrawingSets(releases: true);
private void ShowDrawingSets(bool releases)
private void ShowDrawingSets(bool releases, bool importBom = false)
{
if (!NestStorage.Settings.IsDatabaseMode)
return;
@@ -171,6 +185,7 @@ namespace OpenNest.Forms
form.EnableRelease(nests, OpenServerNest, IsServerNestOpen);
if (releases)
_ = form.ShowReleasesAsync();
form.ImportBomWhenShown = importBom;
form.ShowDialog(this);
}
+7
View File
@@ -235,6 +235,13 @@ changes the hash:
Adding a library drawing to a nest ignores whatever a package holds for these fields
and applies the record's values.
An unchanged DXF file imports with the same entity ids, but from another folder its
package records another `source.path`, and packages saved before entity ids were stable
carry random ones, so the packages' hashes differ. `DrawingPackage.GeometryKey`
compares geometry instead: the SHA-256 of the package entries with `source.path` left
out and every GUID replaced by its order of first appearance. BOM import to a set uses
it to reuse a library drawing whose geometry has not changed.
## Drawing colors
`color` is the drawing's display fill. A drawing writes `customColor: true` when
+53 -1
View File
@@ -414,7 +414,8 @@ a set shows its notes. Below the list are **New Set...**, **Edit...** (also a
double-click or Enter on a set), **More ▾** and **Make Nests...** in bold, the
main action (below). **More ▾**, also the list's right-click
menu (the menu key or Shift+F10 opens it from the keyboard), holds **Archive**
or **Restore**, **History...** and **Releases...** for the selected set.
or **Restore**, **History...** and **Releases...** for the selected set, then
**Import BOM...** ([below](#import-bom-to-a-set-desktop)), which needs no selection.
- **New Set...** and **Edit...** open the set editor
(`OpenNest.Data.DrawingSetEditSession`): name, customer, notes and the member
@@ -447,6 +448,57 @@ or **Restore**, **History...** and **Releases...** for the selected set.
for the selected set) lists revisions newest first with the members of the selected
one, shown with their drawings' current library details.
### Import BOM to a set (desktop)
**Import BOM...** (the Sets tab's **More ▾** menu; **File > Import BOM...** in
Database mode opens the drawing sets window straight into it) turns a BOM into a
new drawing set. It sits in More rather than beside New Set because it is another
way to start a set, used once per job, and the Sets tab keeps New Set, Edit and
Make Nests as its only buttons; File > Import BOM stays the familiar way in.
1. Choose the BOM (`.xlsx`, read by `BomReader`) and the folder of its drawings,
the **Set name** (prefilled from the BOM file name without a trailing " BOM")
and an optional **Customer**, then **Read BOM**. Rows resolve to drawing files
as in File-mode BOM import (`BomImportRows`). Rows that name the same file with
the same material (ignoring case) and thickness are one drawing needing their
total quantity, which becomes its quantity per set (`BomSetDrawings`). Rows
with no file name, no drawing file or a quantity below 1, and files that fail
to import, are listed greyed and left out. Rows with no material or thickness
are kept: the drawing is stored with a blank material and thickness 0, and the
set editor's note column leads to Edit Drawing to fix it before Make Nests.
2. Each drawing is matched to the library (`OpenNest.Data.DrawingSetImport`) by
its identity (D4: normalized customer, name, material and grade, exact
thickness, units and cut type; the name is the file name without extension,
the grade blank, the units the new-nest default and the cut type the one last
saved to the library on this PC) and its geometry:
- an active drawing with the same identity and geometry is **used as it is**;
- one active drawing with the same identity and other geometry gets a **new
revision** with the new package, keeping its details, notes and settings;
- no active match: a **new library drawing**;
- several matches, none with this geometry, or a second BOM drawing with the
same identity as an earlier one: left out with a reason (add the right
drawing in the set editor).
Geometry is compared by package hash, then by
`DrawingPackage.GeometryKey` on the matching drawing's current package
(see [drawing packages](nest-file-format.md#drawing-packages)), since a file read
from another folder records another source path (and drawings stored before
entity ids were stable carry random ones). Reading only queries and downloads.
Changing the BOM, folder or customer afterwards asks for Read BOM again.
3. **Create Set...**: when the import would upload drawings or add revisions, a
confirmation lists each one first; Cancel stores nothing. On OK they are
stored in order (each revision guarded by the revision it was matched
against), and then the set editor opens with a new, unsaved set holding
every imported drawing at its BOM quantity, named and with the customer as
entered and notes naming the BOM file. The set is stored only by the editor's
**Save**; cancelling the editor leaves the confirmed library drawings and no
set. A failed write stops the import, names what was already stored and
leaves the set unchanged; Read BOM again then reuses those drawings, so
importing twice never stores a drawing twice.
Releasing the set (**Make Nests...**) then makes the nests, replacing File-mode
BOM import's Create Nests for Database mode. In File mode, **File > Import BOM...**
still makes nests directly, unchanged.
In the Drawing Library dialog, right-click a drawing for **Used in Sets...** (the sets
whose current revision contains it) or **Add to Set...**: choose an active set
or **New Set**, and the set editor opens with the drawing added at the entered
+12 -1
View File
@@ -58,10 +58,21 @@
"project": "OpenNest.WinForms.Tests",
"method": "OpenNest.WinForms.Tests.Forms.BomImportFormTests.DuplicateRows_CreateNestsButtonImportsOneDrawingWithCombinedQuantity",
"minimum_cases": 1
},
{
"project": "OpenNest.WinForms.Tests",
"method": "OpenNest.WinForms.Tests.Forms.ImportBomToSetFormTests.ImportBom_OpensTheEditorPrefilled_AndCancellingItStoresNoSet",
"minimum_cases": 1
},
{
"project": "OpenNest.WinForms.Tests",
"method": "OpenNest.WinForms.Tests.Forms.ImportBomToSetFormTests.DecliningTheLibraryWrites_OrCancelling_StoresNothing",
"minimum_cases": 1
}
],
"remaining_acceptance": [
"Visual appearance of disabled rows; additional material-dropdown behavior is not covered."
"Visual appearance of disabled rows; additional material-dropdown behavior is not covered.",
"Import BOM to a set against a live nest server (HTTP uploads, revisions and package downloads) is not covered."
]
},
{