diff --git a/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs new file mode 100644 index 00000000..cdd03574 --- /dev/null +++ b/OpenNest.WinForms.Tests/Forms/DrawingLibraryFormTests.cs @@ -0,0 +1,202 @@ +using System.Reflection; +using System.Security.Cryptography; +using System.Windows.Forms; +using OpenNest.Data; +using OpenNest.Forms; +using OpenNest.IO; +using OpenNest.Shapes; + +namespace OpenNest.WinForms.Tests.Forms; + +public class DrawingLibraryFormTests +{ + private const string Server = "http://library.test:8090"; + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(60); + + [Fact] + public void ListsTheNestFilteredPage_AndAddThenAddAgainRaisesTheQuantity() + { + StaTestThread.Run( + () => + { + var library = new FakeLibrary(); + var record = library.Add("SYN-101", "Synthetic Steel", "Grade A", 0.25, Units.Inches, "Deburr both sides"); + var nest = MatchingNest(); + var messages = new List(); + + using var form = new DrawingLibraryForm(library, Server, nest); + form.ShowMessage = (text, _, _) => { messages.Add(text); return DialogResult.OK; }; + form.Show(); + var grid = Field(form, "drawingGrid"); + var notes = Field(form, "notesBox"); + var quantity = Field(form, "quantityBox"); + + PumpUntil(() => grid.Rows.Count == 1, "the library page"); + var query = Assert.Single(library.Queries); + Assert.Equal(("Synthetic Steel", "Grade A", (double?)0.25), (query.MaterialName, query.MaterialGrade, query.Thickness)); + Assert.Equal("SYN-101", grid.Rows[0].Cells["nameColumn"].Value); + Assert.Equal("Laser", grid.Rows[0].Cells["cutTypeColumn"].Value); + Assert.Equal("Deburr both sides", notes.Text); + + quantity.Value = 3; + Run(form.AddSelectedAsync()); + quantity.Value = 2; + Run(form.AddSelectedAsync()); + + Assert.Empty(messages); + var drawing = Assert.Single(nest.Drawings); + Assert.Equal(5, drawing.Quantity.Required); + Assert.Equal((Server, record.Id, record.Revision), (drawing.Library!.ServerKey, drawing.Library.Id, drawing.Library.Revision)); + Assert.Same(drawing, Assert.Single(form.AddedDrawings)); + Assert.Equal(new[] { (record.Id, record.Revision), (record.Id, record.Revision) }, library.Downloads); + }, + TestTimeout, + "The drawing library dialog test did not complete."); + } + + [Fact] + public void MismatchWarning_IsShownBeforeTheAdd_AndCancelLeavesTheNestAlone() + { + StaTestThread.Run( + () => + { + var library = new FakeLibrary(); + library.Add("SYN-202", "Other Steel", "Grade B", 0.1875, Units.Inches, ""); + var nest = MatchingNest(); + var answers = new Queue(new[] { DialogResult.Cancel, DialogResult.OK }); + var messages = new List(); + + using var form = new DrawingLibraryForm(library, Server, nest); + form.ShowMessage = (text, buttons, _) => + { + messages.Add(text); + Assert.Equal(MessageBoxButtons.OKCancel, buttons); + return answers.Dequeue(); + }; + form.Show(); + var grid = Field(form, "drawingGrid"); + var showAll = Field(form, "showAllCheckBox"); + + showAll.Checked = true; + PumpUntil(() => grid.Rows.Count == 1, "the unfiltered page"); + Assert.Equal(("", "", (double?)null), (library.Queries[^1].MaterialName, library.Queries[^1].MaterialGrade, library.Queries[^1].Thickness)); + + Run(form.AddSelectedAsync()); + Assert.Empty(nest.Drawings); + Run(form.AddSelectedAsync()); + + Assert.Equal(2, messages.Count); + Assert.Contains("thickness 0.1875 (nest 0.25)", messages[0]); + Assert.Contains("cut pins are dropped", messages[0]); + Assert.Single(nest.Drawings); + }, + TestTimeout, + "The drawing library mismatch test did not complete."); + } + + [Fact] + public void Refusal_IsShownAndNothingIsAdded() + { + StaTestThread.Run( + () => + { + var library = new FakeLibrary(); + library.Add("SYN-303", "Synthetic Steel", "Grade A", 0.25, Units.Millimeters, ""); + var nest = MatchingNest(); + var messages = new List(); + + using var form = new DrawingLibraryForm(library, Server, nest); + form.ShowMessage = (text, buttons, _) => { messages.Add(text); return DialogResult.OK; }; + form.Show(); + var grid = Field(form, "drawingGrid"); + + PumpUntil(() => grid.Rows.Count == 1, "the library page"); + Run(form.AddSelectedAsync()); + + Assert.Contains("millimeters", Assert.Single(messages)); + Assert.Empty(nest.Drawings); + Assert.Empty(form.AddedDrawings); + }, + TestTimeout, + "The drawing library refusal test did not complete."); + } + + + + private static Nest MatchingNest() => new("Synthetic job") + { + Units = Units.Inches, + Thickness = 0.25, + Material = new Material("Synthetic Steel", "Grade A"), + }; + + private static void Run(Task task) => PumpUntil(() => task.IsCompleted, "the add to finish"); + + private static void PumpUntil(Func condition, string what) + { + var until = DateTime.UtcNow.AddSeconds(15); + while (!condition() && DateTime.UtcNow < until) + { + Application.DoEvents(); + Thread.Sleep(10); + } + + Assert.True(condition(), $"Timed out waiting for {what}."); + } + + private static T Field(object owner, string name) => + (T)owner.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(owner)!; + + /// Serves every record (ignoring filters) and real packages by revision. + private sealed class FakeLibrary : IDrawingLibrary + { + private readonly List _records = new(); + private readonly Dictionary<(Guid, int), byte[]> _packages = new(); + + public List Queries { get; } = new(); + + public List<(Guid, int)> Downloads { get; } = new(); + + public DrawingLibraryRecord Add(string name, string material, string grade, double thickness, Units units, string notes) + { + var drawing = new RectangleShape { Name = name, Length = 4, Width = 2 }.GetDrawing(); + var package = DrawingPackage.Write(drawing, units); + var record = new DrawingLibraryRecord + { + Id = Guid.NewGuid(), + Revision = 2, + Name = name, + Customer = "Synthetic customer", + MaterialName = material, + MaterialGrade = grade, + Thickness = thickness, + Units = units, + CutType = CutType.Laser, + Notes = notes, + PackageSha256 = Convert.ToHexString(SHA256.HashData(package)).ToLowerInvariant(), + SavedAt = new DateTime(2026, 10, 1, 12, 0, 0, DateTimeKind.Utc), + }; + _records.Add(record); + _packages[(record.Id, record.Revision)] = package; + return record; + } + + public Task QueryAsync(DrawingLibraryQuery query, CancellationToken cancellationToken = default) + { + Queries.Add(query); + return Task.FromResult(new DrawingLibraryPage + { + Items = _records.ToArray(), + Total = _records.Count, + Offset = query.Offset, + Limit = query.Limit, + }); + } + + public Task GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default) + { + Downloads.Add((id, revision)); + return Task.FromResult(_packages.TryGetValue((id, revision), out var package) ? package : null); + } + } +} diff --git a/OpenNest/Forms/DrawingLibraryForm.Designer.cs b/OpenNest/Forms/DrawingLibraryForm.Designer.cs new file mode 100644 index 00000000..cfdda052 --- /dev/null +++ b/OpenNest/Forms/DrawingLibraryForm.Designer.cs @@ -0,0 +1,392 @@ +namespace OpenNest.Forms +{ + partial class DrawingLibraryForm + { + private System.ComponentModel.IContainer components = null; + + protected override void Dispose(bool disposing) + { + if (disposing) + { + session?.Dispose(); + if (components != null) + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + System.Windows.Forms.DataGridViewCellStyle rightAligned = new System.Windows.Forms.DataGridViewCellStyle(); + layoutTable = new System.Windows.Forms.TableLayoutPanel(); + filterTable = new System.Windows.Forms.TableLayoutPanel(); + searchLabel = new System.Windows.Forms.Label(); + searchBox = new System.Windows.Forms.TextBox(); + showAllCheckBox = new System.Windows.Forms.CheckBox(); + filterLabel = new System.Windows.Forms.Label(); + drawingGrid = new System.Windows.Forms.DataGridView(); + nameColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + customerColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + materialColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + gradeColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + thicknessColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + cutTypeColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + revisionColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + savedColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + notesGroup = new System.Windows.Forms.GroupBox(); + notesBox = new System.Windows.Forms.TextBox(); + bottomTable = new System.Windows.Forms.TableLayoutPanel(); + previousButton = new System.Windows.Forms.Button(); + nextButton = new System.Windows.Forms.Button(); + summaryLabel = new System.Windows.Forms.Label(); + quantityLabel = new System.Windows.Forms.Label(); + quantityBox = new System.Windows.Forms.NumericUpDown(); + addButton = new System.Windows.Forms.Button(); + closeButton = new System.Windows.Forms.Button(); + statusLabel = new System.Windows.Forms.Label(); + searchTimer = new System.Windows.Forms.Timer(components); + layoutTable.SuspendLayout(); + filterTable.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)drawingGrid).BeginInit(); + notesGroup.SuspendLayout(); + bottomTable.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)quantityBox).BeginInit(); + SuspendLayout(); + // + // layoutTable + // + layoutTable.ColumnCount = 1; + layoutTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + layoutTable.Controls.Add(filterTable, 0, 0); + layoutTable.Controls.Add(drawingGrid, 0, 1); + layoutTable.Controls.Add(notesGroup, 0, 2); + layoutTable.Controls.Add(bottomTable, 0, 3); + layoutTable.Controls.Add(statusLabel, 0, 4); + layoutTable.Dock = System.Windows.Forms.DockStyle.Fill; + layoutTable.Location = new System.Drawing.Point(0, 0); + layoutTable.Name = "layoutTable"; + layoutTable.Padding = new System.Windows.Forms.Padding(8); + layoutTable.RowCount = 5; + layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle()); + layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F)); + layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Absolute, 96F)); + layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle()); + layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle()); + layoutTable.Size = new System.Drawing.Size(984, 601); + layoutTable.TabIndex = 0; + // + // filterTable + // + filterTable.AutoSize = true; + filterTable.ColumnCount = 4; + filterTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + filterTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Absolute, 280F)); + filterTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + filterTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + filterTable.Controls.Add(searchLabel, 0, 0); + filterTable.Controls.Add(searchBox, 1, 0); + filterTable.Controls.Add(showAllCheckBox, 2, 0); + filterTable.Controls.Add(filterLabel, 3, 0); + filterTable.Dock = System.Windows.Forms.DockStyle.Fill; + filterTable.Margin = new System.Windows.Forms.Padding(0, 0, 0, 6); + filterTable.Name = "filterTable"; + filterTable.RowCount = 1; + filterTable.RowStyles.Add(new System.Windows.Forms.RowStyle()); + filterTable.TabIndex = 0; + // + // searchLabel + // + searchLabel.Anchor = System.Windows.Forms.AnchorStyles.Left; + searchLabel.AutoSize = true; + searchLabel.Name = "searchLabel"; + searchLabel.TabIndex = 0; + searchLabel.Text = "Search:"; + // + // searchBox + // + searchBox.Dock = System.Windows.Forms.DockStyle.Fill; + searchBox.Name = "searchBox"; + searchBox.PlaceholderText = "Name, customer or notes"; + searchBox.TabIndex = 1; + searchBox.TextChanged += SearchBox_TextChanged; + // + // showAllCheckBox + // + showAllCheckBox.Anchor = System.Windows.Forms.AnchorStyles.Left; + showAllCheckBox.AutoSize = true; + showAllCheckBox.Margin = new System.Windows.Forms.Padding(12, 3, 3, 3); + showAllCheckBox.Name = "showAllCheckBox"; + showAllCheckBox.TabIndex = 2; + showAllCheckBox.Text = "Show all"; + showAllCheckBox.UseVisualStyleBackColor = true; + showAllCheckBox.CheckedChanged += ShowAllCheckBox_CheckedChanged; + // + // filterLabel + // + filterLabel.Anchor = System.Windows.Forms.AnchorStyles.Left; + filterLabel.AutoSize = true; + filterLabel.ForeColor = System.Drawing.SystemColors.GrayText; + filterLabel.Name = "filterLabel"; + filterLabel.TabIndex = 3; + // + // drawingGrid + // + drawingGrid.AllowUserToAddRows = false; + drawingGrid.AllowUserToDeleteRows = false; + drawingGrid.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { nameColumn, customerColumn, materialColumn, gradeColumn, thicknessColumn, cutTypeColumn, revisionColumn, savedColumn }); + drawingGrid.Dock = System.Windows.Forms.DockStyle.Fill; + drawingGrid.MultiSelect = false; + drawingGrid.Name = "drawingGrid"; + drawingGrid.ReadOnly = true; + drawingGrid.RowHeadersVisible = false; + drawingGrid.SelectionMode = System.Windows.Forms.DataGridViewSelectionMode.FullRowSelect; + drawingGrid.StandardTab = true; + drawingGrid.TabIndex = 1; + drawingGrid.CellDoubleClick += DrawingGrid_CellDoubleClick; + drawingGrid.ColumnHeaderMouseClick += DrawingGrid_ColumnHeaderMouseClick; + drawingGrid.SelectionChanged += DrawingGrid_SelectionChanged; + // + // nameColumn + // + nameColumn.HeaderText = "Name"; + nameColumn.Name = "nameColumn"; + nameColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Programmatic; + nameColumn.Width = 200; + // + // customerColumn + // + customerColumn.HeaderText = "Customer"; + customerColumn.Name = "customerColumn"; + customerColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Programmatic; + customerColumn.Width = 160; + // + // materialColumn + // + materialColumn.HeaderText = "Material"; + materialColumn.Name = "materialColumn"; + materialColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Programmatic; + materialColumn.Width = 140; + // + // gradeColumn + // + gradeColumn.HeaderText = "Grade"; + gradeColumn.Name = "gradeColumn"; + gradeColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Programmatic; + gradeColumn.Width = 100; + // + // thicknessColumn + // + rightAligned.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleRight; + thicknessColumn.DefaultCellStyle = rightAligned; + thicknessColumn.HeaderText = "Thickness"; + thicknessColumn.Name = "thicknessColumn"; + thicknessColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Programmatic; + thicknessColumn.Width = 80; + // + // cutTypeColumn + // + cutTypeColumn.HeaderText = "Cut type"; + cutTypeColumn.Name = "cutTypeColumn"; + cutTypeColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Programmatic; + cutTypeColumn.Width = 80; + // + // revisionColumn + // + revisionColumn.DefaultCellStyle = rightAligned; + revisionColumn.HeaderText = "Revision"; + revisionColumn.Name = "revisionColumn"; + revisionColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Programmatic; + revisionColumn.Width = 70; + // + // savedColumn + // + savedColumn.HeaderText = "Saved"; + savedColumn.Name = "savedColumn"; + savedColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Programmatic; + savedColumn.Width = 130; + // + // notesGroup + // + notesGroup.Controls.Add(notesBox); + notesGroup.Dock = System.Windows.Forms.DockStyle.Fill; + notesGroup.Name = "notesGroup"; + notesGroup.TabIndex = 2; + notesGroup.TabStop = false; + notesGroup.Text = "Notes"; + // + // notesBox + // + notesBox.BorderStyle = System.Windows.Forms.BorderStyle.None; + notesBox.Dock = System.Windows.Forms.DockStyle.Fill; + notesBox.Multiline = true; + notesBox.Name = "notesBox"; + notesBox.ReadOnly = true; + notesBox.ScrollBars = System.Windows.Forms.ScrollBars.Vertical; + notesBox.TabIndex = 0; + notesBox.TabStop = false; + // + // bottomTable + // + bottomTable.AutoSize = true; + bottomTable.ColumnCount = 7; + bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + bottomTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle()); + bottomTable.Controls.Add(previousButton, 0, 0); + bottomTable.Controls.Add(nextButton, 1, 0); + bottomTable.Controls.Add(summaryLabel, 2, 0); + bottomTable.Controls.Add(quantityLabel, 3, 0); + bottomTable.Controls.Add(quantityBox, 4, 0); + bottomTable.Controls.Add(addButton, 5, 0); + bottomTable.Controls.Add(closeButton, 6, 0); + bottomTable.Dock = System.Windows.Forms.DockStyle.Fill; + bottomTable.Margin = new System.Windows.Forms.Padding(0, 6, 0, 0); + bottomTable.Name = "bottomTable"; + bottomTable.RowCount = 1; + bottomTable.RowStyles.Add(new System.Windows.Forms.RowStyle()); + bottomTable.TabIndex = 3; + // + // previousButton + // + previousButton.AutoSize = true; + previousButton.Name = "previousButton"; + previousButton.TabIndex = 0; + previousButton.Text = "< Previous"; + previousButton.UseVisualStyleBackColor = true; + previousButton.Click += PreviousButton_Click; + // + // nextButton + // + nextButton.AutoSize = true; + nextButton.Name = "nextButton"; + nextButton.TabIndex = 1; + nextButton.Text = "Next >"; + nextButton.UseVisualStyleBackColor = true; + nextButton.Click += NextButton_Click; + // + // summaryLabel + // + summaryLabel.Anchor = System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + summaryLabel.AutoEllipsis = true; + summaryLabel.Name = "summaryLabel"; + summaryLabel.TabIndex = 2; + // + // quantityLabel + // + quantityLabel.Anchor = System.Windows.Forms.AnchorStyles.Right; + quantityLabel.AutoSize = true; + quantityLabel.Name = "quantityLabel"; + quantityLabel.TabIndex = 3; + quantityLabel.Text = "Quantity:"; + // + // quantityBox + // + quantityBox.Anchor = System.Windows.Forms.AnchorStyles.Left; + quantityBox.Maximum = new decimal(new int[] { 99999, 0, 0, 0 }); + quantityBox.Minimum = new decimal(new int[] { 1, 0, 0, 0 }); + quantityBox.Name = "quantityBox"; + quantityBox.Size = new System.Drawing.Size(70, 25); + quantityBox.TabIndex = 4; + quantityBox.TextAlign = System.Windows.Forms.HorizontalAlignment.Right; + quantityBox.Value = new decimal(new int[] { 1, 0, 0, 0 }); + // + // addButton + // + addButton.AutoSize = true; + addButton.Enabled = false; + addButton.Margin = new System.Windows.Forms.Padding(12, 3, 3, 3); + addButton.Name = "addButton"; + addButton.TabIndex = 5; + addButton.Text = "Add to Nest"; + addButton.UseVisualStyleBackColor = true; + addButton.Click += AddButton_Click; + // + // closeButton + // + closeButton.AutoSize = true; + closeButton.DialogResult = System.Windows.Forms.DialogResult.Cancel; + closeButton.Name = "closeButton"; + closeButton.TabIndex = 6; + closeButton.Text = "Close"; + closeButton.UseVisualStyleBackColor = true; + // + // statusLabel + // + statusLabel.AutoEllipsis = true; + statusLabel.Dock = System.Windows.Forms.DockStyle.Fill; + statusLabel.Margin = new System.Windows.Forms.Padding(3, 6, 3, 0); + statusLabel.Name = "statusLabel"; + statusLabel.Size = new System.Drawing.Size(100, 20); + statusLabel.TabIndex = 4; + // + // searchTimer + // + searchTimer.Interval = 300; + searchTimer.Tick += SearchTimer_Tick; + // + // DrawingLibraryForm + // + AutoScaleMode = System.Windows.Forms.AutoScaleMode.None; + CancelButton = closeButton; + ClientSize = new System.Drawing.Size(984, 601); + Controls.Add(layoutTable); + Font = new System.Drawing.Font("Segoe UI", 9.75F); + MinimizeBox = false; + MinimumSize = new System.Drawing.Size(720, 420); + Name = "DrawingLibraryForm"; + ShowIcon = false; + ShowInTaskbar = false; + StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; + Text = "Add from Drawing Library"; + Shown += DrawingLibraryForm_Shown; + layoutTable.ResumeLayout(false); + layoutTable.PerformLayout(); + filterTable.ResumeLayout(false); + filterTable.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)drawingGrid).EndInit(); + notesGroup.ResumeLayout(false); + notesGroup.PerformLayout(); + bottomTable.ResumeLayout(false); + bottomTable.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)quantityBox).EndInit(); + ResumeLayout(false); + } + + #endregion + + private System.Windows.Forms.TableLayoutPanel layoutTable; + private System.Windows.Forms.TableLayoutPanel filterTable; + private System.Windows.Forms.Label searchLabel; + private System.Windows.Forms.TextBox searchBox; + private System.Windows.Forms.CheckBox showAllCheckBox; + private System.Windows.Forms.Label filterLabel; + private System.Windows.Forms.DataGridView drawingGrid; + private System.Windows.Forms.DataGridViewTextBoxColumn nameColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn customerColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn materialColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn gradeColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn thicknessColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn cutTypeColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn revisionColumn; + private System.Windows.Forms.DataGridViewTextBoxColumn savedColumn; + private System.Windows.Forms.GroupBox notesGroup; + private System.Windows.Forms.TextBox notesBox; + private System.Windows.Forms.TableLayoutPanel bottomTable; + private System.Windows.Forms.Button previousButton; + private System.Windows.Forms.Button nextButton; + private System.Windows.Forms.Label summaryLabel; + private System.Windows.Forms.Label quantityLabel; + private System.Windows.Forms.NumericUpDown quantityBox; + private System.Windows.Forms.Button addButton; + private System.Windows.Forms.Button closeButton; + private System.Windows.Forms.Label statusLabel; + private System.Windows.Forms.Timer searchTimer; + } +} diff --git a/OpenNest/Forms/DrawingLibraryForm.cs b/OpenNest/Forms/DrawingLibraryForm.cs new file mode 100644 index 00000000..4864f1cd --- /dev/null +++ b/OpenNest/Forms/DrawingLibraryForm.cs @@ -0,0 +1,303 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; +using System.Windows.Forms; +using OpenNest.Controls; +using OpenNest.Data; +using OpenNest.IO; + +namespace OpenNest.Forms +{ + /// + /// Nest > Add from Drawing Library (Database mode). Browses the server drawing library + /// one bounded page at a time through , filtered + /// to the nest's material and thickness unless Show all is ticked. Add downloads the + /// listed revision's exact package, plans it with , + /// shows any warning or refusal first and only then changes the nest. + /// + public partial class DrawingLibraryForm : Form + { + private const string LoadingText = "Loading..."; + + private readonly IDrawingLibrary library; + private readonly string serverUrl; + private readonly Nest nest; + private readonly DrawingLibraryBrowseSession session; + private bool adding; + + /// Shows a message and returns the operator's answer; replaced by tests. + internal Func ShowMessage { get; set; } + + /// + /// The library drawings this dialog added or raised the quantity of, in order. The + /// dialog stays open after each add, so callers check this rather than the dialog result. + /// + public List AddedDrawings { get; } = new(); + + public DrawingLibraryForm(IDrawingLibrary library, string serverUrl, Nest nest) + { + InitializeComponent(); + this.library = library ?? throw new ArgumentNullException(nameof(library)); + this.serverUrl = serverUrl ?? ""; + this.nest = nest ?? throw new ArgumentNullException(nameof(nest)); + session = new DrawingLibraryBrowseSession(library, nest); + ShowMessage = (text, buttons, icon) => MessageBox.Show(this, text, Text, buttons, icon); + + GridStyle.Apply(drawingGrid); + nameColumn.Tag = DrawingSortField.Name; + customerColumn.Tag = DrawingSortField.Customer; + materialColumn.Tag = DrawingSortField.MaterialName; + gradeColumn.Tag = DrawingSortField.MaterialGrade; + thicknessColumn.Tag = DrawingSortField.Thickness; + cutTypeColumn.Tag = DrawingSortField.CutType; + revisionColumn.Tag = DrawingSortField.Revision; + savedColumn.Tag = DrawingSortField.SavedAt; + + Text = $"Add from Drawing Library — {this.serverUrl}"; + showAllCheckBox.Enabled = session.IsFilteredToNest; + UpdateFilterLabel(); + UpdateNavigation(); + } + + private async void DrawingLibraryForm_Shown(object sender, EventArgs e) => + await RunAsync(session.RefreshAsync); + + private void SearchBox_TextChanged(object sender, EventArgs e) + { + searchTimer.Stop(); + searchTimer.Start(); + } + + private async void SearchTimer_Tick(object sender, EventArgs e) + { + searchTimer.Stop(); + await RunAsync(() => session.SetSearchAsync(searchBox.Text)); + } + + private async void ShowAllCheckBox_CheckedChanged(object sender, EventArgs e) + { + var showAll = showAllCheckBox.Checked; + UpdateFilterLabel(showAll); + await RunAsync(() => session.SetShowAllAsync(showAll)); + } + + private async void DrawingGrid_ColumnHeaderMouseClick(object sender, DataGridViewCellMouseEventArgs e) + { + // Sorting a page locally would misrepresent the library; the server sorts all matches. + if (e.Button == MouseButtons.Left && drawingGrid.Columns[e.ColumnIndex].Tag is DrawingSortField field) + await RunAsync(() => session.SortByAsync(field)); + } + + private async void PreviousButton_Click(object sender, EventArgs e) => + await RunAsync(session.PreviousPageAsync); + + private async void NextButton_Click(object sender, EventArgs e) => + await RunAsync(session.NextPageAsync); + + private void DrawingGrid_SelectionChanged(object sender, EventArgs e) + { + notesBox.Text = SelectedRecord()?.Notes ?? ""; + UpdateCommands(); + } + + private async void DrawingGrid_CellDoubleClick(object sender, DataGridViewCellEventArgs e) + { + if (e.RowIndex >= 0) + await AddSelectedAsync(); + } + + private async void AddButton_Click(object sender, EventArgs e) => await AddSelectedAsync(); + + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) + { + if (keyData == Keys.Enter && drawingGrid.ContainsFocus && SelectedRecord() != null) + { + _ = AddSelectedAsync(); + return true; + } + + return base.ProcessCmdKey(ref msg, keyData); + } + + /// + /// Downloads the selected revision, plans it against the nest, shows warnings or the + /// refusal before anything changes, then applies it. The dialog is modal, so the nest + /// cannot change between the plan and the apply. + /// + internal async Task AddSelectedAsync() + { + var record = SelectedRecord(); + if (record == null || adding) + return; + + adding = true; + UpdateCommands(); + statusLabel.Text = $"Downloading '{record.Name}' revision {record.Revision}..."; + var quantity = (int)quantityBox.Value; + try + { + var bytes = await library.GetRevisionPackageAsync(record.Id, record.Revision); + if (IsDisposed) + return; + if (bytes == null) + { + statusLabel.Text = ""; + ShowMessage($"Revision {record.Revision} of '{record.Name}' is no longer on the server.", + MessageBoxButtons.OK, MessageBoxIcon.Warning); + await RunAsync(session.RefreshAsync); + return; + } + + var contents = await Task.Run(() => DrawingPackage.Read(bytes)); + if (IsDisposed) + return; + + var insert = LibraryDrawingInsert.Plan(nest, serverUrl, record, + new LibraryPackage(bytes, contents.Drawing, contents.Units, contents.Warnings), quantity); + if (insert.IsRefused) + { + statusLabel.Text = ""; + ShowMessage(insert.Refusal, MessageBoxButtons.OK, MessageBoxIcon.Warning); + return; + } + + if (insert.Warnings.Count > 0 + && ShowMessage(string.Join(Environment.NewLine + Environment.NewLine, insert.Warnings) + + Environment.NewLine + Environment.NewLine + "Add it to the nest anyway?", + MessageBoxButtons.OKCancel, MessageBoxIcon.Warning) != DialogResult.OK) + { + statusLabel.Text = ""; + return; + } + + insert.Apply(); + if (!AddedDrawings.Contains(insert.Drawing)) + AddedDrawings.Add(insert.Drawing); + statusLabel.Text = insert.Kind == LibraryInsertKind.AddQuantity + ? $"Added {quantity} to '{insert.Drawing.Name}' (now {insert.Drawing.Quantity.Required})." + : $"Added '{insert.Drawing.Name}' revision {record.Revision}, quantity {quantity}."; + } + catch (ServerTooOldException ex) + { + Fail(ex.Message); + } + catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException) + { + // InvalidDataException (an unreadable package) is an IOException too. + Fail($"Could not add '{record.Name}': {ex.Message}"); + } + finally + { + adding = false; + if (!IsDisposed) + UpdateCommands(); + } + } + + private void Fail(string message) + { + if (IsDisposed) + return; + statusLabel.Text = ""; + ShowMessage(message, MessageBoxButtons.OK, MessageBoxIcon.Error); + } + + private async Task RunAsync(Func> request) + { + summaryLabel.Text = LoadingText; + try + { + var started = request(); + UpdateNavigation(); + if (await started && !IsDisposed) + Populate(session.Page); + } + catch (Exception ex) + { + if (!IsDisposed) + { + drawingGrid.Rows.Clear(); + summaryLabel.Text = ex is ServerTooOldException + ? ex.Message + : $"Could not load the drawing library: {ex.Message}"; + } + } + finally + { + if (!IsDisposed) + { + UpdateNavigation(); + // A request that was declined or superseded renders nothing of its own. + if (!session.IsLoading && summaryLabel.Text == LoadingText) + summaryLabel.Text = session.Summary; + UpdateCommands(); + } + } + } + + private void Populate(DrawingLibraryPage page) + { + drawingGrid.Rows.Clear(); + foreach (var record in page?.Items ?? Array.Empty()) + { + var index = drawingGrid.Rows.Add( + record.Name, + record.Customer, + record.MaterialName, + record.MaterialGrade, + FormatThickness(record.Thickness), + record.CutType.ToString(), + record.Revision, + record.SavedAt == default ? "" : record.SavedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture)); + drawingGrid.Rows[index].Tag = record; + } + + foreach (DataGridViewColumn column in drawingGrid.Columns) + { + column.HeaderCell.SortGlyphDirection = column.Tag is DrawingSortField field && field == session.Sort + ? session.Descending ? SortOrder.Descending : SortOrder.Ascending + : SortOrder.None; + } + + notesBox.Text = SelectedRecord()?.Notes ?? ""; + } + + private DrawingLibraryRecord SelectedRecord() => + drawingGrid.CurrentRow?.Tag as DrawingLibraryRecord; + + private void UpdateNavigation() + { + previousButton.Enabled = !session.IsLoading && session.CanGoPrevious; + nextButton.Enabled = !session.IsLoading && session.CanGoNext; + } + + private void UpdateCommands() => + addButton.Enabled = !adding && SelectedRecord() != null; + + private void UpdateFilterLabel(bool showAll = false) + { + if (!session.IsFilteredToNest && !showAll) + { + filterLabel.Text = "This nest has no material or thickness; showing every drawing."; + return; + } + + var parts = new[] + { + session.Thickness is { } thickness ? FormatThickness(thickness) : "", + session.MaterialName, + session.MaterialGrade, + }.Where(part => part.Length > 0); + filterLabel.Text = showAll + ? "Showing every drawing, including other materials and thicknesses." + : $"Showing drawings for this nest: {string.Join(" ", parts)}"; + } + + private static string FormatThickness(double value) => + value.ToString("0.####", CultureInfo.CurrentCulture); + } +} diff --git a/OpenNest/Forms/EditNestForm.Designer.cs b/OpenNest/Forms/EditNestForm.Designer.cs index 38f04ba7..9f0a3833 100644 --- a/OpenNest/Forms/EditNestForm.Designer.cs +++ b/OpenNest/Forms/EditNestForm.Designer.cs @@ -51,6 +51,7 @@ toolStrip2 = new System.Windows.Forms.ToolStrip(); toolStripButton2 = new System.Windows.Forms.ToolStripButton(); shapeLibraryButton = new System.Windows.Forms.ToolStripButton(); + drawingLibraryButton = new System.Windows.Forms.ToolStripButton(); editDrawingsButton = new System.Windows.Forms.ToolStripButton(); toolStripButton3 = new System.Windows.Forms.ToolStripButton(); hideNestedButton = new System.Windows.Forms.ToolStripButton(); @@ -219,7 +220,7 @@ // toolStrip2.GripStyle = System.Windows.Forms.ToolStripGripStyle.Hidden; toolStrip2.ImageScalingSize = new System.Drawing.Size(20, 20); - toolStrip2.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { toolStripButton2, shapeLibraryButton, editDrawingsButton, toolStripButton3, hideNestedButton }); + toolStrip2.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { toolStripButton2, shapeLibraryButton, drawingLibraryButton, editDrawingsButton, toolStripButton3, hideNestedButton }); toolStrip2.Location = new System.Drawing.Point(4, 3); toolStrip2.Name = "toolStrip2"; toolStrip2.Size = new System.Drawing.Size(265, 27); @@ -248,6 +249,16 @@ shapeLibraryButton.Text = "Shape Library"; shapeLibraryButton.Click += ShapeLibrary_Click; // + // drawingLibraryButton + // + drawingLibraryButton.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + drawingLibraryButton.Name = "drawingLibraryButton"; + drawingLibraryButton.Padding = new System.Windows.Forms.Padding(5, 0, 5, 0); + drawingLibraryButton.Size = new System.Drawing.Size(58, 24); + drawingLibraryButton.Text = "Library"; + drawingLibraryButton.ToolTipText = "Add from Drawing Library"; + drawingLibraryButton.Click += DrawingLibrary_Click; + // // editDrawingsButton // editDrawingsButton.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Image; @@ -325,6 +336,7 @@ private System.Windows.Forms.ToolStrip toolStrip2; private System.Windows.Forms.ToolStripButton toolStripButton2; private System.Windows.Forms.ToolStripButton shapeLibraryButton; + private System.Windows.Forms.ToolStripButton drawingLibraryButton; private System.Windows.Forms.ToolStripButton editDrawingsButton; private System.Windows.Forms.ToolStripButton toolStripButton3; private System.Windows.Forms.ToolStripButton hideNestedButton; diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index 0ec6829e..c78e7496 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -9,6 +9,7 @@ using OpenNest.Actions; using OpenNest.CNC.CuttingStrategy; using OpenNest.Collections; using OpenNest.Controls; +using OpenNest.Data; using OpenNest.Diagnostics; using OpenNest.Engine; using OpenNest.Engine.Sequencing; @@ -439,6 +440,8 @@ namespace OpenNest.Forms drawingListBox1.DeleteRequested += drawingListBox1_DeleteRequested; drawingListBox1.DrawingPickedUp += drawingListBox1_DrawingPickedUp; drawingListBox1.DrawingDropped += drawingListBox1_DrawingDropped; + Activated += (_, _) => UpdateDrawingLibraryCommand(); + UpdateDrawingLibraryCommand(); } public void UpdatePlateList() @@ -1186,6 +1189,42 @@ namespace OpenNest.Forms UpdateDrawingList(); } + private void DrawingLibrary_Click(object sender, EventArgs e) + { + AddFromDrawingLibrary(); + } + + /// The library is on the nest server, so it is offered in Database mode only. + public void UpdateDrawingLibraryCommand() + { + var enabled = NestStorage.Settings.IsDatabaseMode; + drawingLibraryButton.Enabled = enabled; + drawingLibraryButton.ToolTipText = enabled ? "Add from Drawing Library" : NestStorage.DatabaseModeOnly; + } + + /// Opens the drawing library browser and lists whatever it added. + public void AddFromDrawingLibrary() + { + if (!NestStorage.Settings.IsDatabaseMode) + return; + + IDrawingLibrary library; + try + { + library = NestStorage.CreateDrawingLibrary(); + } + catch (ArgumentException ex) + { + MessageBox.Show(this, ex.Message, "Drawing Library", MessageBoxButtons.OK, MessageBoxIcon.Error); + return; + } + + using var form = new DrawingLibraryForm(library, NestStorage.Settings.ServerUrl, Nest); + form.ShowDialog(this); + if (form.AddedDrawings.Count > 0) + UpdateDrawingList(); + } + private void EditDrawingsInConverter_Click(object sender, EventArgs e) { if (Nest.Drawings.Count == 0) diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index 9d8dcf1e..7155706f 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -91,6 +91,7 @@ namespace OpenNest.Forms mnuNestClamps = new System.Windows.Forms.ToolStripMenuItem(); mnuNestImportDrawing = new System.Windows.Forms.ToolStripMenuItem(); mnuNestShapeLibrary = new System.Windows.Forms.ToolStripMenuItem(); + mnuNestDrawingLibrary = new System.Windows.Forms.ToolStripMenuItem(); toolStripMenuItem7 = new System.Windows.Forms.ToolStripSeparator(); mnuNestFirstPlate = new System.Windows.Forms.ToolStripMenuItem(); mnuNestLastPlate = new System.Windows.Forms.ToolStripMenuItem(); @@ -573,10 +574,11 @@ namespace OpenNest.Forms // // mnuNest // - mnuNest.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuNestEdit, mnuNestClamps, mnuNestImportDrawing, mnuNestShapeLibrary, toolStripMenuItem7, mnuNestFirstPlate, mnuNestLastPlate, toolStripMenuItem6, mnuNestNextPlate, mnuNestPreviousPlate, toolStripMenuItem12, runAutoNestToolStripMenuItem, autoSequenceAllPlatesToolStripMenuItem, mnuNestRemoveEmptyPlates, mnuNestValidate, mnuNestPost, toolStripMenuItem19, calculateCutTimeToolStripMenuItem, toolStripMenuItem22, mnuNestAutomaticCutOff, mnuNestPlanCutting, mnuNestAssignLeadIns, mnuNestRemoveLeadIns }); + mnuNest.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuNestEdit, mnuNestClamps, mnuNestImportDrawing, mnuNestShapeLibrary, mnuNestDrawingLibrary, toolStripMenuItem7, mnuNestFirstPlate, mnuNestLastPlate, toolStripMenuItem6, mnuNestNextPlate, mnuNestPreviousPlate, toolStripMenuItem12, runAutoNestToolStripMenuItem, autoSequenceAllPlatesToolStripMenuItem, mnuNestRemoveEmptyPlates, mnuNestValidate, mnuNestPost, toolStripMenuItem19, calculateCutTimeToolStripMenuItem, toolStripMenuItem22, mnuNestAutomaticCutOff, mnuNestPlanCutting, mnuNestAssignLeadIns, mnuNestRemoveLeadIns }); mnuNest.Name = "mnuNest"; mnuNest.Size = new System.Drawing.Size(43, 20); mnuNest.Text = "&Nest"; + mnuNest.DropDownOpening += NestMenu_Opening; // // mnuNestEdit // @@ -607,6 +609,13 @@ namespace OpenNest.Forms mnuNestShapeLibrary.Text = "Shape Library"; mnuNestShapeLibrary.Click += ShapeLibrary_Click; // + // mnuNestDrawingLibrary + // + mnuNestDrawingLibrary.Name = "mnuNestDrawingLibrary"; + mnuNestDrawingLibrary.Size = new System.Drawing.Size(205, 22); + mnuNestDrawingLibrary.Text = "Add from Drawing Library..."; + mnuNestDrawingLibrary.Click += DrawingLibrary_Click; + // // toolStripMenuItem7 // toolStripMenuItem7.Name = "toolStripMenuItem7"; @@ -1334,6 +1343,7 @@ namespace OpenNest.Forms private System.Windows.Forms.ToolStripMenuItem mnuNestClamps; private System.Windows.Forms.ToolStripMenuItem mnuNestImportDrawing; private System.Windows.Forms.ToolStripMenuItem mnuNestShapeLibrary; + private System.Windows.Forms.ToolStripMenuItem mnuNestDrawingLibrary; private System.Windows.Forms.ToolStripSeparator toolStripMenuItem7; private System.Windows.Forms.ToolStripMenuItem mnuNestFirstPlate; private System.Windows.Forms.ToolStripMenuItem mnuNestLastPlate; diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 6b82e072..1c06bc4a 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -939,6 +939,8 @@ namespace OpenNest.Forms { NestStorage.Save(dlg.SelectedMode, dlg.ServerUrl); statusLabel1.Text = $"Nest storage: {dlg.SelectedMode}"; + foreach (var form in MdiChildren.OfType()) + form.UpdateDrawingLibraryCommand(); } catch (Exception ex) { @@ -1558,6 +1560,20 @@ namespace OpenNest.Forms activeForm.UpdateDrawingList(); } + private void DrawingLibrary_Click(object sender, EventArgs e) + { + activeForm?.AddFromDrawingLibrary(); + } + + private void NestMenu_Opening(object sender, EventArgs e) + { + // The drawing library lives on the nest server; File mode has nothing to browse. + var databaseMode = NestStorage.Settings.IsDatabaseMode; + mnuNest.DropDown.ShowItemToolTips = true; + mnuNestDrawingLibrary.Enabled = activeForm != null && databaseMode; + mnuNestDrawingLibrary.ToolTipText = databaseMode ? "" : NestStorage.DatabaseModeOnly; + } + private void EditNest_Click(object sender, EventArgs e) { if (activeForm == null) diff --git a/OpenNest/Forms/NestStorage.cs b/OpenNest/Forms/NestStorage.cs index 23531ded..b3468757 100644 --- a/OpenNest/Forms/NestStorage.cs +++ b/OpenNest/Forms/NestStorage.cs @@ -15,6 +15,11 @@ namespace OpenNest.Forms private static RemoteNestRepository cachedRepository; private static string cachedServerUrl; + private static RemoteDrawingLibrary cachedLibrary; + private static string cachedLibraryUrl; + + /// Tooltip for drawing library commands while File mode is active. + public const string DatabaseModeOnly = "Database mode only"; /// Current settings, loaded once at process start. public static NestStorageSettings Settings => settings; @@ -47,5 +52,21 @@ namespace OpenNest.Forms return cachedRepository; } + + /// + /// The shared drawing library client for the current server URL, recreated only when + /// the URL changes. Throws for an invalid URL. + /// + public static IDrawingLibrary CreateDrawingLibrary() + { + if (cachedLibrary == null || cachedLibraryUrl != settings.ServerUrl) + { + cachedLibrary?.Dispose(); + cachedLibrary = new RemoteDrawingLibrary(settings.ServerUrl); + cachedLibraryUrl = settings.ServerUrl; + } + + return cachedLibrary; + } } } diff --git a/docs/nest-storage.md b/docs/nest-storage.md index d29f796c..a971a53e 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -144,8 +144,8 @@ only the requested page of metadata (never archive bytes): ## Drawing library endpoints The server also keeps a shared library of drawings (schema v4). The desktop -workflow is not built yet; these routes and `OpenNest.Data.RemoteDrawingLibrary` -are the storage layer for it. A drawing's geometry travels as an opaque +can add library drawings to a nest (below); saving to the library is not built +yet. A drawing's geometry travels as an opaque **drawing package**: a `.nest` archive holding exactly one drawing and no plates, written and read on the client by `OpenNest.IO.DrawingPackage`. The server never parses it. @@ -199,6 +199,30 @@ v4 database, so rolling back after the upgrade needs the pre-upgrade backup (see `tar` backup includes the drawing tables, but `nest_manifest.py` and `restore_check` do not verify drawings yet. +### Add from Drawing Library (desktop) + +**Nest > Add from Drawing Library...** and the **Library** button above the +drawing list open a paged browser of active library drawings (Database mode +only; in File mode both are disabled with "Database mode only"). It starts +filtered to the nest's material, grade and thickness; **Show all** lifts that +filter. Selecting a drawing shows its notes. **Add to Nest** (or double-click / +Enter) downloads that exact revision, checks its SHA-256 against the record and +plans the insert (`OpenNest.Data.LibraryDrawingInsert`) before changing anything: + +- Material, grade or thickness differing from the nest: a warning names each + difference and the operator confirms. The drawing is added, but its cut pins + will not apply; the nest's cutting settings do. +- Different units: refused (no conversion). +- The same revision from the same server already in the nest: only the + quantity is raised. A different revision of that drawing: refused; change + revisions through Update Drawings. +- Another drawing with the same name: refused, naming both. + +The added drawing is an independent copy carrying a library link (server key, +id, revision and a snapshot of the library details) that is saved with the nest +(see [the nest file format](nest-file-format.md#drawing-library-links)). Editing it +in the nest never changes the library. + ## Storage `OpenNest.Server.NestDatabase` uses SQLite (`Microsoft.Data.Sqlite`) with