diff --git a/OpenNest.Data/NestSetMerge.cs b/OpenNest.Data/NestSetMerge.cs
new file mode 100644
index 00000000..2ca70fcc
--- /dev/null
+++ b/OpenNest.Data/NestSetMerge.cs
@@ -0,0 +1,210 @@
+using OpenNest;
+
+namespace OpenNest.Data;
+
+/// One library drawing a nest would add to a set, with the nest's required quantity.
+public sealed class NestSetCandidate
+{
+ internal NestSetCandidate(DrawingLibraryLink link, string name, int quantity)
+ {
+ Link = link;
+ Name = name;
+ Quantity = quantity;
+ }
+
+ /// The link of the first nest drawing copied from this library drawing.
+ public DrawingLibraryLink Link { get; }
+
+ public Guid DrawingId => Link.Id;
+
+ /// The nest drawing's name.
+ public string Name { get; }
+
+ /// The required quantity, summed over every nest drawing copied from this library drawing.
+ public int Quantity { get; }
+}
+
+/// A nest drawing left out of the set, and why.
+public sealed record NestSetSkip(string Name, string Reason);
+
+/// A drawing the set already lists: its quantity there is kept, not replaced or added to.
+public sealed record NestSetKept(string Name, int SetQuantity, int NestQuantity);
+
+/// What changed in the set editor.
+public sealed class NestSetMergeResult
+{
+ internal NestSetMergeResult(IReadOnlyList added, IReadOnlyList kept,
+ IReadOnlyList skipped, IReadOnlyList warnings)
+ {
+ Added = added;
+ Kept = kept;
+ Skipped = skipped;
+ Warnings = warnings;
+ }
+
+ /// The drawings appended to the set, in order.
+ public IReadOnlyList Added { get; }
+
+ public IReadOnlyList Kept { get; }
+
+ /// Every nest drawing left out: those the plan skipped, then those the library no longer holds.
+ public IReadOnlyList Skipped { get; }
+
+ /// Added drawings the operator should know about (archived, or newer in the library).
+ public IReadOnlyList Warnings { get; }
+
+ /// True when the operator only needs to review the editor, with nothing else to read first.
+ public bool IsClean => Kept.Count == 0 && Skipped.Count == 0 && Warnings.Count == 0;
+
+ /// The operator-facing account of what was added, kept, skipped and why.
+ public string Describe(string setName)
+ {
+ var lines = new List();
+ var target = string.IsNullOrWhiteSpace(setName) ? "the new set" : $"set '{setName}'";
+ lines.Add(Added.Count == 0
+ ? $"No drawings were added to {target}."
+ : $"Added {Added.Count} drawing{(Added.Count == 1 ? "" : "s")} to {target}.");
+
+ if (Kept.Count > 0)
+ {
+ lines.Add("");
+ lines.Add("Already in the set (its quantity is kept; edit it there to change it):");
+ lines.AddRange(Kept.Select(k => $"- {k.Name}: set {k.SetQuantity}, nest {k.NestQuantity}"));
+ }
+
+ if (Skipped.Count > 0)
+ {
+ lines.Add("");
+ lines.Add("Skipped:");
+ lines.Add(Lines(Skipped));
+ }
+
+ if (Warnings.Count > 0)
+ {
+ lines.Add("");
+ lines.Add("Check:");
+ lines.AddRange(Warnings.Select(w => "- " + w));
+ }
+
+ return string.Join(Environment.NewLine, lines);
+ }
+
+ internal static string Lines(IEnumerable skipped) =>
+ string.Join(Environment.NewLine, skipped.Select(s => $"- {s.Name}: {s.Reason}"));
+}
+
+///
+/// Adds a nest's library drawings to a drawing set (Slice 6). is a snapshot of
+/// the nest taken before any I/O: each library drawing on this server once, with the required
+/// quantities of its nest copies summed. Drawings that were never saved to the library, came from
+/// another server or have no required quantity are skipped with a reason; cutoffs are not parts
+/// and are left out silently. then reads each drawing's current library
+/// record and appends it to a , which the operator reviews and
+/// saves. A drawing the set already lists keeps the set's quantity: a nest released from a set
+/// carries its multiplier, so copying its quantities back would scale the kit.
+///
+public sealed class NestSetMerge
+{
+ private const string UnlinkedReason = "not in the drawing library (save it to the library first)";
+
+ private NestSetMerge(IReadOnlyList candidates, IReadOnlyList skipped)
+ {
+ Candidates = candidates;
+ Skipped = skipped;
+ }
+
+ /// The library drawings to add, in the drawing list's (name) order.
+ public IReadOnlyList Candidates { get; }
+
+ public IReadOnlyList Skipped { get; }
+
+ /// The skipped drawings, one "- name: reason" line each.
+ public string DescribeSkipped() => NestSetMergeResult.Lines(Skipped);
+
+ public static NestSetMerge Plan(Nest nest, string serverUrl)
+ {
+ ArgumentNullException.ThrowIfNull(nest);
+ var serverKey = ServerUrl.Key(serverUrl);
+ if (serverKey.Length == 0)
+ throw new ArgumentException("A nest server URL is required.", nameof(serverUrl));
+
+ var skipped = new List();
+ var linked = new List<(DrawingLibraryLink Link, string Name, int Quantity)>();
+ foreach (var drawing in nest.Drawings.Where(d => d != null && !d.IsCutOff).OrderBy(d => d.Name))
+ {
+ var name = drawing.Name ?? "";
+ var link = drawing.Library;
+ if (link == null)
+ skipped.Add(new NestSetSkip(name, UnlinkedReason));
+ else if (!string.Equals(link.ServerKey, serverKey, StringComparison.Ordinal))
+ skipped.Add(new NestSetSkip(name, $"from the drawing library on another server ({link.ServerKey})"));
+ else if (drawing.Quantity.Required < 1)
+ skipped.Add(new NestSetSkip(name, "has no required quantity"));
+ else
+ linked.Add((link, name, drawing.Quantity.Required));
+ }
+
+ var candidates = new List();
+ foreach (var group in linked.GroupBy(l => l.Link.Id))
+ {
+ var first = group.First();
+ var quantity = group.Sum(l => (long)l.Quantity);
+ if (quantity > DrawingSetRules.MaxQuantity)
+ {
+ skipped.Add(new NestSetSkip(first.Name,
+ $"quantity {quantity:N0} is more than a set member allows ({DrawingSetRules.MaxQuantity:N0})"));
+ continue;
+ }
+
+ candidates.Add(new NestSetCandidate(first.Link, first.Name, (int)quantity));
+ }
+
+ return new NestSetMerge(candidates, skipped);
+ }
+
+ ///
+ /// Reads each candidate's current library record and appends the drawings
+ /// does not already list. Nothing is saved; a read failure throws
+ /// before changes.
+ ///
+ public async Task ApplyAsync(IDrawingLibraryStore library, DrawingSetEditSession edit,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(library);
+ ArgumentNullException.ThrowIfNull(edit);
+
+ var skipped = new List(Skipped);
+ var kept = new List();
+ var found = new List<(NestSetCandidate Candidate, DrawingLibraryRecord Record)>();
+ foreach (var candidate in Candidates)
+ {
+ var existing = edit.Members.FirstOrDefault(m => m.DrawingId == candidate.DrawingId);
+ if (existing != null)
+ {
+ kept.Add(new NestSetKept(candidate.Name, existing.Quantity, candidate.Quantity));
+ continue;
+ }
+
+ var record = await library.GetAsync(candidate.DrawingId, cancellationToken);
+ if (record == null)
+ skipped.Add(new NestSetSkip(candidate.Name, "no longer in the drawing library on this server"));
+ else
+ found.Add((candidate, record));
+ }
+
+ var added = new List();
+ var warnings = new List();
+ foreach (var (candidate, record) in found)
+ {
+ edit.AddDrawings(new[] { record }, candidate.Quantity);
+ added.Add(candidate);
+ if (record.Archived)
+ warnings.Add($"'{record.Name}' is archived in the library; releases skip it unless it is ticked back in.");
+ else if (record.Revision > candidate.Link.Revision)
+ warnings.Add($"'{record.Name}' is at revision {record.Revision} in the library; this nest has revision "
+ + $"{candidate.Link.Revision}. Sets release the latest revision.");
+ }
+
+ return new NestSetMergeResult(added, kept, skipped, warnings);
+ }
+}
diff --git a/OpenNest.Tests/Data/NestSetMergeTests.cs b/OpenNest.Tests/Data/NestSetMergeTests.cs
new file mode 100644
index 00000000..b934ea09
--- /dev/null
+++ b/OpenNest.Tests/Data/NestSetMergeTests.cs
@@ -0,0 +1,185 @@
+using OpenNest.Data;
+
+namespace OpenNest.Tests.Data;
+
+public class NestSetMergeTests
+{
+ private const string Server = "http://synthetic-server:5280";
+
+ [Fact]
+ public void Plan_TakesThisServersLinkedDrawings_AndSkipsTheRestWithAReason()
+ {
+ var a = SetReleasePlanTests.Drawing("SYN-A");
+ var b = SetReleasePlanTests.Drawing("SYN-B");
+ var nest = new Nest();
+ nest.Drawings.Add(Linked("SYN-B", b, 3));
+ nest.Drawings.Add(Linked("SYN-A", a, 2));
+ nest.Drawings.Add(Drawing("SYN-LOOSE", 4));
+ nest.Drawings.Add(Linked("SYN-OTHER", SetReleasePlanTests.Drawing("SYN-OTHER"), 1, server: "http://other-server"));
+ nest.Drawings.Add(Linked("SYN-NONE", SetReleasePlanTests.Drawing("SYN-NONE"), 0));
+ var cutoff = Drawing("SYN-CUTOFF", 1);
+ cutoff.IsCutOff = true;
+ nest.Drawings.Add(cutoff);
+
+ // The trailing slash names the same server.
+ var merge = NestSetMerge.Plan(nest, Server + "/");
+
+ Assert.Equal(new[] { (a.Id, "SYN-A", 2), (b.Id, "SYN-B", 3) },
+ merge.Candidates.Select(c => (c.DrawingId, c.Name, c.Quantity)));
+ Assert.Equal(new[] { "SYN-LOOSE", "SYN-NONE", "SYN-OTHER" }, merge.Skipped.Select(s => s.Name));
+ Assert.Contains("save it to the library first", merge.Skipped[0].Reason);
+ Assert.Contains("no required quantity", merge.Skipped[1].Reason);
+ Assert.Contains("http://other-server", merge.Skipped[2].Reason);
+ Assert.Equal("- SYN-LOOSE: " + merge.Skipped[0].Reason, merge.DescribeSkipped().Split(Environment.NewLine)[0]);
+ }
+
+ [Fact]
+ public void Plan_SumsCopiesOfOneLibraryDrawing_AndSkipsAQuantityPastTheSetLimit()
+ {
+ var a = SetReleasePlanTests.Drawing("SYN-A");
+ var big = SetReleasePlanTests.Drawing("SYN-BIG");
+ var nest = new Nest();
+ nest.Drawings.Add(Linked("SYN-A", a, 2));
+ nest.Drawings.Add(Linked("SYN-A COPY", a, 5));
+ nest.Drawings.Add(Linked("SYN-BIG", big, DrawingSetRules.MaxQuantity));
+ nest.Drawings.Add(Linked("SYN-BIG COPY", big, 1));
+
+ var merge = NestSetMerge.Plan(nest, Server);
+
+ var candidate = Assert.Single(merge.Candidates);
+ Assert.Equal(("SYN-A", 7), (candidate.Name, candidate.Quantity));
+ var skip = Assert.Single(merge.Skipped);
+ Assert.Equal("SYN-BIG", skip.Name);
+ Assert.Contains((DrawingSetRules.MaxQuantity + 1).ToString("N0"), skip.Reason);
+ }
+
+ [Fact]
+ public async Task Apply_AppendsNewDrawings_KeepsExistingQuantities_AndWarnsWhatToCheck()
+ {
+ var kept = SetReleasePlanTests.Drawing("SYN-KEPT");
+ var fresh = SetReleasePlanTests.Drawing("SYN-FRESH");
+ var newer = SetReleasePlanTests.Drawing("SYN-NEWER");
+ var archived = SetReleasePlanTests.Drawing("SYN-ARCHIVED", archived: true);
+ var gone = SetReleasePlanTests.Drawing("SYN-GONE");
+ var library = new Library(kept, fresh, newer, archived);
+ var nest = new Nest();
+ nest.Drawings.Add(Linked("SYN-KEPT", kept, 8));
+ nest.Drawings.Add(Linked("SYN-FRESH", fresh, 3));
+ nest.Drawings.Add(Linked("SYN-NEWER", newer, 1, revision: newer.Revision - 1));
+ nest.Drawings.Add(Linked("SYN-ARCHIVED", archived, 1));
+ nest.Drawings.Add(Linked("SYN-GONE", gone, 1));
+ nest.Drawings.Add(Drawing("SYN-LOOSE", 1));
+ var sets = new InMemoryDrawingSets();
+ var set = await sets.CreateAsync(SetReleasePlanTests.Set((kept, 2)));
+ var edit = new DrawingSetEditSession(sets, await sets.GetAsync(set.Id), "synthetic");
+
+ var result = await NestSetMerge.Plan(nest, Server).ApplyAsync(library, edit);
+
+ Assert.Equal(new[] { (kept.Id, 2), (archived.Id, 1), (fresh.Id, 3), (newer.Id, 1) },
+ edit.Members.Select(m => (m.DrawingId, m.Quantity)));
+ Assert.Same(fresh, edit.Members[2].Drawing);
+ Assert.Equal(new[] { "SYN-ARCHIVED", "SYN-FRESH", "SYN-NEWER" }, result.Added.Select(a => a.Name));
+ Assert.Equal(new NestSetKept("SYN-KEPT", 2, 8), Assert.Single(result.Kept));
+ Assert.Equal(new[] { "SYN-LOOSE", "SYN-GONE" }, result.Skipped.Select(s => s.Name));
+ Assert.Equal(2, result.Warnings.Count);
+ Assert.Contains("'SYN-ARCHIVED' is archived", result.Warnings[0]);
+ Assert.Contains($"revision {newer.Revision} in the library; this nest has revision {newer.Revision - 1}", result.Warnings[1]);
+ Assert.False(result.IsClean);
+ // A member the set already lists is not read from the library.
+ Assert.Equal(new[] { archived.Id, fresh.Id, gone.Id, newer.Id }, library.Reads);
+
+ var text = result.Describe(set.Name);
+ Assert.StartsWith($"Added 3 drawings to set '{set.Name}'.", text);
+ Assert.Contains("- SYN-KEPT: set 2, nest 8", text);
+ Assert.Contains("- SYN-GONE: no longer in the drawing library", text);
+ Assert.Contains("Check:", text);
+ }
+
+ [Fact]
+ public async Task Apply_ToANewSet_IsCleanWhenEveryDrawingIsCurrent()
+ {
+ var a = SetReleasePlanTests.Drawing("SYN-A");
+ var nest = new Nest();
+ nest.Drawings.Add(Linked("SYN-A", a, 4));
+ var edit = new DrawingSetEditSession(new InMemoryDrawingSets(), null, "synthetic");
+
+ var result = await NestSetMerge.Plan(nest, Server).ApplyAsync(new Library(a), edit);
+
+ Assert.True(result.IsClean);
+ Assert.Equal((a.Id, 4), edit.Members.Select(m => (m.DrawingId, m.Quantity)).Single());
+ Assert.Equal("Added 1 drawing to the new set.", result.Describe(null));
+ }
+
+ [Fact]
+ public async Task Apply_LeavesTheEditorUnchanged_WhenALibraryReadFails()
+ {
+ var a = SetReleasePlanTests.Drawing("SYN-A");
+ var b = SetReleasePlanTests.Drawing("SYN-B");
+ var nest = new Nest();
+ nest.Drawings.Add(Linked("SYN-A", a, 1));
+ nest.Drawings.Add(Linked("SYN-B", b, 1));
+ var edit = new DrawingSetEditSession(new InMemoryDrawingSets(), null, "synthetic");
+ var library = new Library(a, b) { FailOn = b.Id };
+
+ await Assert.ThrowsAsync(() => NestSetMerge.Plan(nest, Server).ApplyAsync(library, edit));
+
+ Assert.Empty(edit.Members);
+ }
+
+ private static Drawing Drawing(string name, int required)
+ {
+ var drawing = new Drawing(name);
+ drawing.Quantity.Required = required;
+ return drawing;
+ }
+
+ private static Drawing Linked(string name, DrawingLibraryRecord record, int required, string server = Server,
+ int? revision = null)
+ {
+ var drawing = Drawing(name, required);
+ drawing.Library = new DrawingLibraryLink(server, record.Id, revision ?? record.Revision, record.CutType,
+ record.Thickness, record.MaterialName, record.MaterialGrade, record.Units, record.Notes, "");
+ return drawing;
+ }
+
+ private sealed class Library : IDrawingLibraryStore
+ {
+ private readonly Dictionary _records;
+
+ public Library(params DrawingLibraryRecord[] records) => _records = records.ToDictionary(r => r.Id);
+
+ public List Reads { get; } = new();
+
+ public Guid FailOn { get; init; }
+
+ public Task GetAsync(Guid id, CancellationToken cancellationToken = default)
+ {
+ Reads.Add(id);
+ if (id == FailOn)
+ throw new IOException("Synthetic connection refused.");
+ return Task.FromResult(_records.GetValueOrDefault(id));
+ }
+
+ public Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task GetRevisionsAsync(Guid id, int offset = 0,
+ int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task UploadAsync(DrawingLibraryRecord record, byte[] package,
+ CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task UpdatePackageAsync(Guid id, DrawingLibraryRecord record, byte[] package,
+ int expectedRevision, CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+
+ public Task UpdateMetadataAsync(Guid id, DrawingLibraryRecord record, int expectedRevision,
+ CancellationToken cancellationToken = default) =>
+ throw new NotSupportedException();
+ }
+}
diff --git a/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs b/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs
index e0f3d1eb..691ebb62 100644
--- a/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs
+++ b/OpenNest.WinForms.Tests/Forms/DrawingSetFormsTests.cs
@@ -350,6 +350,113 @@ public class DrawingSetFormsTests
"The set history test did not complete.");
}
+ [Fact]
+ public void NestDrawingsToSet_AppendsTheNestsLibraryDrawings_KeepsSetQuantities_AndReportsSkips()
+ {
+ StaTestThread.Run(
+ () =>
+ {
+ var library = new FakeLibrary();
+ var existing = library.Add("SYN-A", "Synthetic Steel", 0.25);
+ var added = library.Add("SYN-B", "Synthetic Steel", 0.25);
+ var sets = new InMemoryDrawingSets();
+ var set = Run(sets.CreateAsync(new DrawingSetRecord
+ {
+ Id = Guid.NewGuid(),
+ Name = "Synthetic kit",
+ Members = new[] { new DrawingSetMember { DrawingId = existing.Id, Quantity = 1, Drawing = existing } },
+ }));
+ var nest = MatchingNest();
+ nest.Drawings.Add(LinkedDrawing(existing, 6));
+ nest.Drawings.Add(LinkedDrawing(added, 4));
+ nest.Drawings.Add(new Drawing("SYN-LOOSE") { Quantity = { Required = 2 } });
+ var messages = new List();
+
+ var command = new NestDrawingsToSet(null, sets, library, Server);
+ command.ShowMessage = (text, buttons, _) =>
+ {
+ messages.Add(text);
+ Assert.Equal(MessageBoxButtons.OKCancel, buttons);
+ return DialogResult.OK;
+ };
+ command.ChooseSet = () => (true, set);
+ command.EditSet = edit => Run(edit.SaveAsync());
+ var saved = Run(command.RunAsync(nest));
+
+ Assert.Equal(2, saved.Revision);
+ Assert.Equal(new[] { (existing.Id, 1), (added.Id, 4) }, saved.Members.Select(m => (m.DrawingId, m.Quantity)));
+ var message = Assert.Single(messages);
+ Assert.StartsWith("Added 1 drawing to set 'Synthetic kit'.", message);
+ Assert.Contains("- SYN-A: set 1, nest 6", message);
+ Assert.Contains("- SYN-LOOSE: not in the drawing library", message);
+ },
+ TestTimeout,
+ "The nest drawings to set test did not complete.");
+ }
+
+ [Fact]
+ public void NestDrawingsToSet_NamesANewSetAfterTheNest_AndAsksNothingWhenEveryDrawingIsCurrent()
+ {
+ StaTestThread.Run(
+ () =>
+ {
+ var library = new FakeLibrary();
+ var a = library.Add("SYN-A", "Synthetic Steel", 0.25);
+ var sets = new InMemoryDrawingSets();
+ var nest = MatchingNest();
+ nest.Customer = "Synthetic customer";
+ nest.Drawings.Add(LinkedDrawing(a, 3));
+ DrawingSetEditSession edited = null;
+
+ var command = new NestDrawingsToSet(null, sets, library, Server);
+ command.ShowMessage = (text, _, _) => throw new Xunit.Sdk.XunitException(text);
+ command.ChooseSet = () => (true, null);
+ command.EditSet = edit =>
+ {
+ edited = edit;
+ return null;
+ };
+ Assert.Null(Run(command.RunAsync(nest)));
+
+ Assert.True(edited!.IsNew);
+ Assert.Equal(("Synthetic job", "Synthetic customer"), (edited.Name, edited.Customer));
+ Assert.Equal((a.Id, 3), edited.Members.Select(m => (m.DrawingId, m.Quantity)).Single());
+ Assert.Empty(sets.Creates);
+ },
+ TestTimeout,
+ "The nest drawings to new set test did not complete.");
+ }
+
+ [Fact]
+ public void NestDrawingsToSet_WithoutLibraryDrawings_SaysSoBeforeChoosingASet()
+ {
+ StaTestThread.Run(
+ () =>
+ {
+ var nest = MatchingNest();
+ nest.Drawings.Add(new Drawing("SYN-LOOSE") { Quantity = { Required = 1 } });
+ var messages = new List();
+
+ var command = new NestDrawingsToSet(null, new InMemoryDrawingSets(), new FakeLibrary(), Server);
+ command.ShowMessage = (text, _, _) => { messages.Add(text); return DialogResult.OK; };
+ command.ChooseSet = () => throw new Xunit.Sdk.XunitException("No set should be chosen.");
+ Assert.Null(Run(command.RunAsync(nest)));
+
+ var message = Assert.Single(messages);
+ Assert.Contains("Save them to the drawing library first", message);
+ Assert.Contains("- SYN-LOOSE:", message);
+ },
+ TestTimeout,
+ "The nest without library drawings test did not complete.");
+ }
+
+ private static Drawing LinkedDrawing(DrawingLibraryRecord record, int required) => new(record.Name)
+ {
+ Quantity = { Required = required },
+ Library = new DrawingLibraryLink(Server, record.Id, record.Revision, record.CutType, record.Thickness,
+ record.MaterialName, record.MaterialGrade, record.Units, record.Notes, ""),
+ };
+
private static Nest MatchingNest() => new("Synthetic job")
{
Units = Units.Inches,
@@ -421,7 +528,7 @@ public class DrawingSetFormsTests
throw new NotSupportedException();
public Task GetAsync(Guid id, CancellationToken cancellationToken = default) =>
- throw new NotSupportedException();
+ Task.FromResult(_records.FirstOrDefault(r => r.Id == id));
public Task GetRevisionsAsync(Guid id, int offset = 0,
int limit = DrawingLibraryRules.DefaultRevisionLimit, CancellationToken cancellationToken = default) =>
diff --git a/OpenNest/Controls/DrawingListBox.cs b/OpenNest/Controls/DrawingListBox.cs
index 1c8834dd..159d66ae 100644
--- a/OpenNest/Controls/DrawingListBox.cs
+++ b/OpenNest/Controls/DrawingListBox.cs
@@ -35,6 +35,8 @@ namespace OpenNest.Controls
if (SelectedItem is Drawing drawing)
SaveToLibraryRequested?.Invoke(this, drawing);
};
+ var addToSetItem = new ToolStripMenuItem("Add Library Drawings to Set...");
+ addToSetItem.Click += (s, e) => AddToSetRequested?.Invoke(this, EventArgs.Empty);
var saveToLibrarySeparator = new ToolStripSeparator();
var openInConverterItem = new ToolStripMenuItem("Open in CAD Converter...");
openInConverterItem.Click += (s, e) =>
@@ -56,9 +58,13 @@ namespace OpenNest.Controls
saveToLibraryItem.Visible = saveToLibrarySeparator.Visible = SaveToLibraryRequested != null;
saveToLibraryItem.Enabled = SaveToLibraryUnavailableReason == null;
saveToLibraryItem.ToolTipText = SaveToLibraryUnavailableReason ?? "";
+ addToSetItem.Visible = AddToSetRequested != null;
+ addToSetItem.Enabled = SaveToLibraryUnavailableReason == null;
+ addToSetItem.ToolTipText = SaveToLibraryUnavailableReason ?? "";
openInConverterItem.Visible = openInConverterSeparator.Visible = OpenInConverterRequested != null;
};
menu.Items.Add(saveToLibraryItem);
+ menu.Items.Add(addToSetItem);
menu.Items.Add(saveToLibrarySeparator);
menu.Items.Add(openInConverterItem);
menu.Items.Add(openInConverterSeparator);
@@ -71,7 +77,16 @@ namespace OpenNest.Controls
/// Raised when Save to Drawing Library is chosen for the selected drawing.
public event EventHandler SaveToLibraryRequested;
- /// Why Save to Drawing Library is disabled, shown as its tooltip; null enables it.
+ ///
+ /// Raised when Add Library Drawings to Set is chosen; it acts on the whole nest, not the
+ /// selected drawing.
+ ///
+ public event EventHandler AddToSetRequested;
+
+ ///
+ /// Why Save to Drawing Library and Add Library Drawings to Set are disabled, shown as their
+ /// tooltip; null enables them.
+ ///
public string SaveToLibraryUnavailableReason { get; set; }
/// Raised when Open in CAD Converter is chosen for the selected drawing.
diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs
index dafc0c16..5cfcffdb 100644
--- a/OpenNest/Forms/EditNestForm.cs
+++ b/OpenNest/Forms/EditNestForm.cs
@@ -46,6 +46,7 @@ namespace OpenNest.Forms
private readonly Timer updateDrawingListTimer;
private bool updatingPlateList;
+ private bool addingToSet;
private CutSimulatorForm cutSimulatorForm;
private Panel plateHeaderPanel;
@@ -444,6 +445,7 @@ namespace OpenNest.Forms
drawingListBox1.Units = Nest.Units;
drawingListBox1.DeleteRequested += drawingListBox1_DeleteRequested;
drawingListBox1.SaveToLibraryRequested += drawingListBox1_SaveToLibraryRequested;
+ drawingListBox1.AddToSetRequested += drawingListBox1_AddToSetRequested;
drawingListBox1.OpenInConverterRequested += drawingListBox1_OpenInConverterRequested;
drawingListBox1.DrawingPickedUp += drawingListBox1_DrawingPickedUp;
drawingListBox1.DrawingDropped += drawingListBox1_DrawingDropped;
@@ -1367,6 +1369,44 @@ namespace OpenNest.Forms
UpdateDrawingList();
}
+ private async void drawingListBox1_AddToSetRequested(object sender, EventArgs e)
+ {
+ await AddLibraryDrawingsToSetAsync();
+ }
+
+ ///
+ /// Adds the nest's library drawings, with their required quantities, to a chosen or new
+ /// drawing set, which the operator reviews and saves in the set editor.
+ ///
+ public async Task AddLibraryDrawingsToSetAsync()
+ {
+ if (addingToSet || !NestStorage.Settings.IsDatabaseMode)
+ return;
+
+ IDrawingLibraryStore library;
+ IDrawingSetStore sets;
+ try
+ {
+ library = NestStorage.CreateDrawingLibrary();
+ sets = NestStorage.CreateDrawingSetStore();
+ }
+ catch (ArgumentException ex)
+ {
+ MessageBox.Show(this, ex.Message, "Drawing Sets", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ return;
+ }
+
+ addingToSet = true;
+ try
+ {
+ await new NestDrawingsToSet(this, sets, library, NestStorage.Settings.ServerUrl).RunAsync(Nest);
+ }
+ finally
+ {
+ addingToSet = false;
+ }
+ }
+
private void EditDrawingsInConverter_Click(object sender, EventArgs e)
{
if (Nest.Drawings.Count == 0)
diff --git a/OpenNest/Forms/NestDrawingsToSet.cs b/OpenNest/Forms/NestDrawingsToSet.cs
new file mode 100644
index 00000000..c34e820f
--- /dev/null
+++ b/OpenNest/Forms/NestDrawingsToSet.cs
@@ -0,0 +1,120 @@
+using System;
+using System.IO;
+using System.Net.Http;
+using System.Threading.Tasks;
+using System.Windows.Forms;
+using OpenNest.Data;
+
+namespace OpenNest.Forms
+{
+ ///
+ /// Drawing list > Add Library Drawings to Set: chooses a set (or a new one), appends the
+ /// nest's library drawings with their required quantities through ,
+ /// reports anything kept, skipped or worth checking, then opens the set editor so the
+ /// operator reviews and saves. Nothing is stored until the editor saves.
+ ///
+ internal sealed class NestDrawingsToSet
+ {
+ private const string Caption = "Add Library Drawings to Set";
+
+ private readonly IDrawingSetStore sets;
+ private readonly IDrawingLibraryStore library;
+ private readonly string serverUrl;
+
+ /// Shows a message and returns the operator's answer; replaced by tests.
+ internal Func ShowMessage { get; set; }
+
+ /// Chooses the set: Ok false when cancelled, otherwise the listed set or null for a new one; replaced by tests.
+ internal Func<(bool Ok, DrawingSetRecord Listed)> ChooseSet { get; set; }
+
+ /// Shows the set editor and returns the saved record, or null; replaced by tests.
+ internal Func EditSet { get; set; }
+
+ public NestDrawingsToSet(IWin32Window owner, IDrawingSetStore sets, IDrawingLibraryStore library, string serverUrl)
+ {
+ this.sets = sets ?? throw new ArgumentNullException(nameof(sets));
+ this.library = library ?? throw new ArgumentNullException(nameof(library));
+ this.serverUrl = serverUrl ?? "";
+ ShowMessage = (text, buttons, icon) => MessageBox.Show(owner, text, Caption, buttons, icon);
+ ChooseSet = () =>
+ {
+ using var form = new ChooseDrawingSetForm(sets, library, this.serverUrl);
+ return form.ShowDialog(owner) == DialogResult.OK ? (true, form.PickedSet) : (false, null);
+ };
+ EditSet = edit =>
+ {
+ using var form = new EditDrawingSetForm(edit, library, this.serverUrl);
+ return form.ShowDialog(owner) == DialogResult.OK ? form.Saved : null;
+ };
+ }
+
+ ///
+ /// Runs the command for , planned before the first prompt so later
+ /// changes to the nest do not reach the set. Returns the saved set, or null.
+ ///
+ public async Task RunAsync(Nest nest)
+ {
+ ArgumentNullException.ThrowIfNull(nest);
+ var merge = NestSetMerge.Plan(nest, serverUrl);
+ if (merge.Candidates.Count == 0)
+ {
+ var text = "None of this nest's drawings can be added to a set. Save them to the drawing library first.";
+ if (merge.Skipped.Count > 0)
+ text += Environment.NewLine + Environment.NewLine + "Skipped:" + Environment.NewLine + merge.DescribeSkipped();
+ ShowMessage(text, MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return null;
+ }
+
+ var (ok, listed) = ChooseSet();
+ if (!ok)
+ return null;
+
+ DrawingSetEditSession edit;
+ NestSetMergeResult result;
+ try
+ {
+ DrawingSetRecord current = null;
+ if (listed != null)
+ {
+ current = await sets.GetAsync(listed.Id);
+ if (current == null || current.Archived)
+ {
+ ShowMessage(current == null
+ ? $"Set '{listed.Name}' no longer exists on the server."
+ : $"Set '{listed.Name}' was archived. Restore it before adding drawings.",
+ MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return null;
+ }
+ }
+
+ edit = new DrawingSetEditSession(sets, current, Environment.UserName);
+ if (current == null)
+ {
+ edit.Name = nest.Name ?? "";
+ edit.Customer = nest.Customer ?? "";
+ }
+ result = await merge.ApplyAsync(library, edit);
+ }
+ catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException)
+ {
+ // ServerTooOldException is an IOException too.
+ ShowMessage($"Could not read the drawing library: {ex.Message}", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ return null;
+ }
+
+ var setName = listed?.Name;
+ if (result.Added.Count == 0)
+ {
+ ShowMessage(result.Describe(setName), MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return null;
+ }
+
+ if (!result.IsClean
+ && ShowMessage(result.Describe(setName) + Environment.NewLine + Environment.NewLine
+ + "Review and save the set?", MessageBoxButtons.OKCancel, MessageBoxIcon.Information) != DialogResult.OK)
+ return null;
+
+ return EditSet(edit);
+ }
+ }
+}
diff --git a/docs/nest-storage.md b/docs/nest-storage.md
index 2206dc9f..d5239095 100644
--- a/docs/nest-storage.md
+++ b/docs/nest-storage.md
@@ -360,6 +360,20 @@ 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
quantity for review and Save.
+The nest's drawing list has **Add Library Drawings to Set...** (right-click any
+drawing; it acts on the whole nest, `OpenNest.Data.NestSetMerge`). It takes each
+drawing copied from this server's library once, in list order, with its required
+quantity (copies of one library drawing are summed). Drawings never saved to the
+library, linked to another server or with no required quantity are skipped and
+named; cutoffs are left out. If none remain it says so before anything else.
+Otherwise choose an active set or **New Set** (named after the nest, with its
+customer): the set's current library records are read, drawings the set already
+lists **keep the set's quantity** (a released nest carries its multiplier, so
+copying its quantities back would scale the kit), and members are added at the
+library's latest revision. Anything kept, skipped, archived in the library or
+newer there than in the nest is listed first, then the set editor opens for
+review and Save; nothing is stored before then.
+
**Release...** (the Sets tab, when the dialog was opened from a nest) reads the
selected active set with its members and opens the release dialog. It lists one
row per material group (`OpenNest.Data.SetReleasePlan`): material, grade,