diff --git a/OpenNest.Data/DrawingSetImport.cs b/OpenNest.Data/DrawingSetImport.cs
new file mode 100644
index 00000000..ed296b8e
--- /dev/null
+++ b/OpenNest.Data/DrawingSetImport.cs
@@ -0,0 +1,319 @@
+using System.Security.Cryptography;
+
+namespace OpenNest.Data;
+
+/// A drawing to import into a set: its library details, package and quantity per set.
+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;
+ }
+
+ /// How the operator knows the drawing, such as "PT01 (items 3, 7)".
+ public string Label { get; }
+
+ /// The details a new library drawing gets; customer, name, material, grade, thickness, units and cut type are its identity (D4).
+ public DrawingLibraryRecord Metadata { get; }
+
+ /// The canonical drawing package, compared by geometry with the library's.
+ public byte[] Package { get; }
+
+ public long Quantity { get; }
+}
+
+/// What an import does with one drawing.
+public enum DrawingSetImportAction
+{
+ /// An active library drawing with the same identity holds the same geometry: it is used as it is.
+ Reuse,
+
+ /// One active library drawing has the same identity but other geometry: it gains the next revision.
+ Revise,
+
+ /// No active library drawing has this identity: a new one is uploaded.
+ Create,
+
+ /// The drawing cannot be imported; says why.
+ Skip,
+}
+
+/// One planned drawing: the action and the library drawing it uses or revises.
+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; }
+
+ /// The library drawing reused or revised, as listed when planned.
+ public DrawingLibraryRecord? Existing { get; }
+
+ /// Why a skipped drawing is left out; empty otherwise.
+ public string Reason { get; }
+
+ /// The id a created drawing is uploaded under, allocated when planned.
+ public Guid NewId { get; }
+
+ /// The operator-facing account of the action, such as "New revision 4 (geometry changed)".
+ 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,
+ };
+}
+
+/// A library write failed part-way; lists what was stored before it.
+public sealed class DrawingSetImportException : IOException
+{
+ internal DrawingSetImportException(string message, IReadOnlyList written, Exception inner)
+ : base(message, inner)
+ {
+ Written = written;
+ }
+
+ public IReadOnlyList Written { get; }
+}
+
+///
+/// Imports drawings (for example a BOM's) into a new drawing set through the library.
+/// 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.
+///
+/// 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 , 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.
+///
+///
+public sealed class DrawingSetImport
+{
+ private DrawingSetImport(IReadOnlyList entries)
+ {
+ Entries = entries;
+ }
+
+ /// Every item, in the order given.
+ public IReadOnlyList Entries { get; }
+
+ public IEnumerable Imported => Entries.Where(e => e.Action != DrawingSetImportAction.Skip);
+
+ public int Count(DrawingSetImportAction action) => Entries.Count(e => e.Action == action);
+
+ /// True when applying stores anything in the library.
+ public bool HasWrites => Entries.Any(e => e.Action is DrawingSetImportAction.Create or DrawingSetImportAction.Revise);
+
+ ///
+ /// A key equal for packages with the same geometry (OpenNest.IO.DrawingPackage.GeometryKey):
+ /// 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 for an unreadable package.
+ ///
+ public static async Task PlanAsync(IDrawingLibrary library, IEnumerable items,
+ Func geometryKey, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(library);
+ ArgumentNullException.ThrowIfNull(items);
+ ArgumentNullException.ThrowIfNull(geometryKey);
+
+ var entries = new List();
+ var planned = new List();
+ 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 FindSameGeometryAsync(IDrawingLibrary library,
+ DrawingSetImportItem item, IReadOnlyList matches, Func 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;
+ }
+
+ /// The library changes makes, one line each, for the operator to confirm.
+ public string DescribeWrites()
+ {
+ var lines = new List();
+ 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);
+ }
+
+ ///
+ /// Stores the planned uploads and revisions, in order, then adds every imported drawing
+ /// to 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, is left unchanged and a
+ /// lists what was stored before it.
+ ///
+ public async Task> ApplyAsync(IDrawingLibraryStore library,
+ DrawingSetEditSession edit, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(library);
+ ArgumentNullException.ThrowIfNull(edit);
+
+ var written = new List();
+ 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();
+}
diff --git a/OpenNest.IO/Bom/BomImportRows.cs b/OpenNest.IO/Bom/BomImportRows.cs
index 005c6f3e..234b9151 100644
--- a/OpenNest.IO/Bom/BomImportRows.cs
+++ b/OpenNest.IO/Bom/BomImportRows.cs
@@ -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;
}
+
+ ///
+ /// The job a BOM file is for: its file name without the extension or a trailing
+ /// " BOM" ("Frame A BOM.xlsx" is "Frame A").
+ ///
+ 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;
+ }
}
diff --git a/OpenNest.IO/Bom/BomSetDrawings.cs b/OpenNest.IO/Bom/BomSetDrawings.cs
new file mode 100644
index 00000000..9b8592f9
--- /dev/null
+++ b/OpenNest.IO/Bom/BomSetDrawings.cs
@@ -0,0 +1,192 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace OpenNest.IO.Bom;
+
+///
+/// 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.
+///
+public sealed class BomSetDrawing
+{
+ internal BomSetDrawing(
+ string name,
+ string material,
+ double thickness,
+ long quantity,
+ string dxfPath,
+ IReadOnlyList rows,
+ Drawing drawing,
+ byte[] package
+ )
+ {
+ Name = name;
+ Material = material;
+ Thickness = thickness;
+ Quantity = quantity;
+ DxfPath = dxfPath;
+ Rows = rows;
+ Drawing = drawing;
+ Package = package;
+ }
+
+ /// The drawing file's name without its extension.
+ public string Name { get; }
+
+ /// The BOM material, trimmed; blank when the BOM has none.
+ public string Material { get; }
+
+ /// The BOM thickness, or 0 when the BOM has none.
+ public double Thickness { get; }
+
+ /// The total quantity of the rows that name this drawing.
+ public long Quantity { get; }
+
+ public string DxfPath { get; }
+
+ /// The BOM rows this drawing came from, in BOM order.
+ public IReadOnlyList Rows { get; }
+
+ public Drawing Drawing { get; }
+
+ /// The drawing as a canonical .
+ public byte[] Package { get; }
+
+ /// True when the drawing still needs a material or thickness before it can be released.
+ public bool NeedsDetails => Material.Length == 0 || Thickness <= 0;
+
+ /// "PT01 (items 3, 7)", or just the name when the rows have no item numbers.
+ 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)})";
+ }
+ }
+}
+
+/// A BOM row that does not reach the set, and why.
+public sealed record BomSetSkip(BomPartRow Row, string Name, string Reason);
+
+///
+/// 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.
+///
+public sealed class BomSetDrawings
+{
+ private BomSetDrawings(IReadOnlyList drawings, IReadOnlyList skipped)
+ {
+ Drawings = drawings;
+ Skipped = skipped;
+ }
+
+ /// The drawings in the order their first row appears in the BOM.
+ public IReadOnlyList Drawings { get; }
+
+ public IReadOnlyList Skipped { get; }
+
+ ///
+ /// Imports each drawing file named by once and packages it in
+ /// . 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.
+ ///
+ public static BomSetDrawings Build(IEnumerable rows, Units units)
+ {
+ ArgumentNullException.ThrowIfNull(rows);
+ var skipped = new List();
+ var kept = new List();
+ 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();
+ var imported = new Dictionary(
+ 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();
+ }
+}
diff --git a/OpenNest.IO/DrawingPackage.cs b/OpenNest.IO/DrawingPackage.cs
index 47d5a14b..fb7784c0 100644
--- a/OpenNest.IO/DrawingPackage.cs
+++ b/OpenNest.IO/DrawingPackage.cs
@@ -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
}
}
+ ///
+ /// 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.
+ ///
+ 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())
+ (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(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)
diff --git a/OpenNest.Tests/Bom/BomSetImportTests.cs b/OpenNest.Tests/Bom/BomSetImportTests.cs
new file mode 100644
index 00000000..5faa86f7
--- /dev/null
+++ b/OpenNest.Tests/Bom/BomSetImportTests.cs
@@ -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;
+
+///
+/// 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).
+///
+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
+ {
+ 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 { 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 { 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(() => 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 { 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 Drawings(params (string Name, double Size)[] files)
+ {
+ var items = new List();
+ 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 Items(IEnumerable 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 { 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"));
+ }
+}
diff --git a/OpenNest.Tests/Data/InMemoryDrawingLibrary.cs b/OpenNest.Tests/Data/InMemoryDrawingLibrary.cs
new file mode 100644
index 00000000..95edf76e
--- /dev/null
+++ b/OpenNest.Tests/Data/InMemoryDrawingLibrary.cs
@@ -0,0 +1,161 @@
+using System.Security.Cryptography;
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+///
+/// 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.
+///
+internal sealed class InMemoryDrawingLibrary : IDrawingLibraryStore
+{
+ private readonly Dictionary> _revisions = new();
+ private readonly Dictionary _packages = new();
+ private DateTime _clock = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+
+ /// Fail the write with this index (0 is the next write) without storing it.
+ public int? FailWrite { get; set; }
+
+ /// Every write requested, in order: ("upload" | "update", id).
+ public List<(string Kind, Guid Id)> Writes { get; } = new();
+
+ public IEnumerable Current => _revisions.Values.Select(r => r[^1]);
+
+ public DrawingLibraryRecord Get(Guid id) => _revisions[id][^1];
+
+ /// Stores revision 1 directly, as though another operator had saved it.
+ 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 { record };
+ return record;
+ }
+
+ public Task 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,
+ });
+ }
+
+ /// Every package download requested: (id, revision).
+ public List<(Guid Id, int Revision)> Downloads { get; } = new();
+
+ public Task 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 GetAsync(Guid id, CancellationToken cancellationToken = default) =>
+ Task.FromResult(_revisions.TryGetValue(id, out var list) ? list[^1] : null);
+
+ public Task GetRevisionsAsync(Guid id, int offset = 0,
+ int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default)
+ {
+ if (!_revisions.TryGetValue(id, out var list))
+ return Task.FromResult(null);
+ var newestFirst = Enumerable.Reverse(list).ToList();
+ return Task.FromResult(new DrawingLibraryPage
+ {
+ Items = newestFirst.Skip(offset).Take(limit).ToList(),
+ Total = list.Count,
+ Offset = offset,
+ Limit = limit,
+ });
+ }
+
+ public Task 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 { Stored(record, record.Id, 1, package) };
+ return Task.FromResult(Get(record.Id));
+ }
+
+ public Task 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 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);
+}
diff --git a/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs b/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs
index 55822a26..1b79b533 100644
--- a/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs
+++ b/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs
@@ -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(browser, "showArchivedCheckBox").Checked = false;
PumpUntil(() => grid.Rows.Count == 1, "the active set");
grid.CurrentCell = null;
Assert.True(Field