From b65876c03159c074a35dc7d1be0789fedbbd581f Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 20 Oct 2026 15:24:10 -0400 Subject: [PATCH] feat(desktop): add a nest's library drawings to a drawing set Operators often build a nest first and only then want it kept as a reusable kit. The drawing list's new "Add Library Drawings to Set..." takes every drawing copied from this server's library, sums copies of one library drawing, and opens the set editor on a chosen or new set for review and Save. Unlinked, other-server and zero-quantity drawings are named as skipped instead of silently dropped. Members the set already lists keep the set's quantity: a nest released from a set carries its kit multiplier, so copying its quantities back would scale the kit on every round trip. Members are added at the library's latest revision (S1), with a note when the nest holds an older one or the drawing is archived. Co-Authored-By: Claude Opus 5.5 --- OpenNest.Data/NestSetMerge.cs | 210 ++++++++++++++++++ OpenNest.Tests/Data/NestSetMergeTests.cs | 185 +++++++++++++++ .../Forms/DrawingSetFormsTests.cs | 109 ++++++++- OpenNest/Controls/DrawingListBox.cs | 17 +- OpenNest/Forms/EditNestForm.cs | 40 ++++ OpenNest/Forms/NestDrawingsToSet.cs | 120 ++++++++++ docs/nest-storage.md | 14 ++ 7 files changed, 693 insertions(+), 2 deletions(-) create mode 100644 OpenNest.Data/NestSetMerge.cs create mode 100644 OpenNest.Tests/Data/NestSetMergeTests.cs create mode 100644 OpenNest/Forms/NestDrawingsToSet.cs 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,