feat(desktop): Add from Drawing Library

Operators can now pull shared library drawings into a nest. Nest > Add from
Drawing Library... and a Library button above the drawing list open a paged
browser (name, customer, material, grade, thickness, cut type, revision, saved)
with a notes preview and a quantity. It starts filtered to the nest's material,
grade and thickness, with Show all to lift that.

Add downloads the listed revision's immutable package, not the current one, so
a revision saved meanwhile can never be what lands in the nest. The insert is
planned first and every warning or refusal is shown before the nest changes;
the dialog is modal, so the nest cannot move between plan and apply. The dialog
stays open for further adds and the drawing list refreshes on close.

Both entries are Database mode only: the library lives on the nest server, so
in File mode they are disabled with a "Database mode only" tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-16 15:33:19 -04:00
1 parent 175782d8f8
commit a1bd44d0fa
9 files changed
+1023 -4

No files matched your search

@@ -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<string>();
using var form = new DrawingLibraryForm(library, Server, nest);
form.ShowMessage = (text, _, _) => { messages.Add(text); return DialogResult.OK; };
form.Show();
var grid = Field<DataGridView>(form, "drawingGrid");
var notes = Field<TextBox>(form, "notesBox");
var quantity = Field<NumericUpDown>(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<DialogResult>(new[] { DialogResult.Cancel, DialogResult.OK });
var messages = new List<string>();
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<DataGridView>(form, "drawingGrid");
var showAll = Field<CheckBox>(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<string>();
using var form = new DrawingLibraryForm(library, Server, nest);
form.ShowMessage = (text, buttons, _) => { messages.Add(text); return DialogResult.OK; };
form.Show();
var grid = Field<DataGridView>(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<bool> condition, string what)
{
var until = DateTime.UtcNow.AddSeconds(15);
while (!condition() && DateTime.UtcNow < until)
{
Application.DoEvents();
Thread.Sleep(10);
}
Assert.True(condition(), $"Timed out waiting for {what}.");
}
private static T Field<T>(object owner, string name) =>
(T)owner.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(owner)!;
/// <summary>Serves every record (ignoring filters) and real packages by revision.</summary>
private sealed class FakeLibrary : IDrawingLibrary
{
private readonly List<DrawingLibraryRecord> _records = new();
private readonly Dictionary<(Guid, int), byte[]> _packages = new();
public List<DrawingLibraryQuery> 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<DrawingLibraryPage> 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<byte[]?> GetRevisionPackageAsync(Guid id, int revision, CancellationToken cancellationToken = default)
{
Downloads.Add((id, revision));
return Task.FromResult(_packages.TryGetValue((id, revision), out var package) ? package : null);
}
}
}
+392
View File
@@ -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;
}
}
+303
View File
@@ -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
{
/// <summary>
/// Nest &gt; Add from Drawing Library (Database mode). Browses the server drawing library
/// one bounded page at a time through <see cref="DrawingLibraryBrowseSession"/>, filtered
/// to the nest's material and thickness unless Show all is ticked. Add downloads the
/// listed revision's exact package, plans it with <see cref="LibraryDrawingInsert"/>,
/// shows any warning or refusal first and only then changes the nest.
/// </summary>
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;
/// <summary>Shows a message and returns the operator's answer; replaced by tests.</summary>
internal Func<string, MessageBoxButtons, MessageBoxIcon, DialogResult> ShowMessage { get; set; }
/// <summary>
/// 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.
/// </summary>
public List<Drawing> 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);
}
/// <summary>
/// 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.
/// </summary>
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<Task<bool>> 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<DrawingLibraryRecord>())
{
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);
}
}
+13 -1
View File
@@ -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;
+39
View File
@@ -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();
}
/// <summary>The library is on the nest server, so it is offered in Database mode only.</summary>
public void UpdateDrawingLibraryCommand()
{
var enabled = NestStorage.Settings.IsDatabaseMode;
drawingLibraryButton.Enabled = enabled;
drawingLibraryButton.ToolTipText = enabled ? "Add from Drawing Library" : NestStorage.DatabaseModeOnly;
}
/// <summary>Opens the drawing library browser and lists whatever it added.</summary>
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)
+11 -1
View File
@@ -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;
+16
View File
@@ -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<EditNestForm>())
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)
+21
View File
@@ -15,6 +15,11 @@ namespace OpenNest.Forms
private static RemoteNestRepository cachedRepository;
private static string cachedServerUrl;
private static RemoteDrawingLibrary cachedLibrary;
private static string cachedLibraryUrl;
/// <summary>Tooltip for drawing library commands while File mode is active.</summary>
public const string DatabaseModeOnly = "Database mode only";
/// <summary>Current settings, loaded once at process start.</summary>
public static NestStorageSettings Settings => settings;
@@ -47,5 +52,21 @@ namespace OpenNest.Forms
return cachedRepository;
}
/// <summary>
/// The shared drawing library client for the current server URL, recreated only when
/// the URL changes. Throws <see cref="ArgumentException"/> for an invalid URL.
/// </summary>
public static IDrawingLibrary CreateDrawingLibrary()
{
if (cachedLibrary == null || cachedLibraryUrl != settings.ServerUrl)
{
cachedLibrary?.Dispose();
cachedLibrary = new RemoteDrawingLibrary(settings.ServerUrl);
cachedLibraryUrl = settings.ServerUrl;
}
return cachedLibrary;
}
}
}
+26 -2
View File
@@ -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