diff --git a/OpenNest.WinForms.Tests/Controls/DrawingSetPreviewTests.cs b/OpenNest.WinForms.Tests/Controls/DrawingSetPreviewTests.cs index 35a41282..83c35fde 100644 --- a/OpenNest.WinForms.Tests/Controls/DrawingSetPreviewTests.cs +++ b/OpenNest.WinForms.Tests/Controls/DrawingSetPreviewTests.cs @@ -1,7 +1,7 @@ using OpenNest.Controls; using OpenNest.Data; -using OpenNest.Forms; using OpenNest.Geometry; +using OpenNest.Tests.Data; namespace OpenNest.WinForms.Tests.Controls; @@ -89,56 +89,84 @@ public class DrawingSetPreviewTests view.ShowDrawings(drawings); - Assert.Equal(2, view.Parts.Count); + Assert.Equal(2, view.ShownParts.Count); Assert.Equal(2, view.Plate.Parts.Count); - var large = view.Parts[0].BoundingBox; - var small = view.Parts[1].BoundingBox; + var large = view.ShownParts[0].BoundingBox; + var small = view.ShownParts[1].BoundingBox; Assert.Equal((48, 20), (System.Math.Round(large.Length, 6), System.Math.Round(large.Width, 6))); Assert.Equal((2, 1), (System.Math.Round(small.Length, 6), System.Math.Round(small.Width, 6))); Assert.True(large.Right <= small.Left || small.Right <= large.Left || large.Top <= small.Bottom || small.Top <= large.Bottom); - Assert.Equal((float)view.Layout.Scale, view.ViewScale, 3); + Assert.Equal((float)view.PackedLayout.Scale, view.ViewScale, 3); // Resizing repacks to the new view; sizes never change. view.Size = new System.Drawing.Size(400, 900); - Assert.Equal(48, System.Math.Round(view.Parts[0].BoundingBox.Length, 6)); - Assert.Equal((float)view.Layout.Scale, view.ViewScale, 3); + Assert.Equal(48, System.Math.Round(view.ShownParts[0].BoundingBox.Length, 6)); + Assert.Equal((float)view.PackedLayout.Scale, view.ViewScale, 3); }, TimeSpan.FromMinutes(1), "The STA test did not complete."); [Fact] - public void Form_LoadsEachMemberAndCountsTheMissingOnes() => StaTestThread.Run(() => + public void Browser_PreviewsTheHighlightedSetBesideTheList() => StaTestThread.Run(() => { var plate = new DrawingLibraryRecord { Id = Guid.NewGuid(), Name = "SYN-PLATE", Revision = 2 }; var gone = new DrawingLibraryRecord { Id = Guid.NewGuid(), Name = "SYN-GONE", Revision = 1 }; - var set = new DrawingSetRecord + var sets = new InMemoryDrawingSets(); + Run(sets.CreateAsync(new DrawingSetRecord { + Id = Guid.NewGuid(), Name = "Synthetic kit", - Revision = 3, Members = new[] { new DrawingSetMember { DrawingId = plate.Id, Quantity = 4, Drawing = plate }, new DrawingSetMember { DrawingId = gone.Id, Quantity = 1, Drawing = gone }, }, - }; + })); var requested = new List<(Guid, int)>(); - using var form = new DrawingSetPreviewForm(set, (record, _) => + using var form = new System.Windows.Forms.Form { Size = new System.Drawing.Size(1000, 600) }; + var browser = new DrawingSetBrowser { Dock = System.Windows.Forms.DockStyle.Fill }; + form.Controls.Add(browser); + browser.Initialize(sets, new InMemoryDrawingLibrary(), "http://library.test:8090"); + browser.LoadPreviewDrawing = (record, _) => { requested.Add((record.Id, record.Revision)); return Task.FromResult(record == plate ? Rectangle(record.Name, 10, 5) : null); - }); - form.Preview.Size = new System.Drawing.Size(800, 600); - var load = form.LoadAsync(); + }; + form.Show(); + var refresh = browser.RefreshAsync(); + PumpUntil(() => refresh.IsCompleted && browser.SelectedSet != null); + + // The pane beside the list keeps its width; the list takes the rest. + Assert.InRange(browser.PreviewBox.Width, 300, 440); + PumpUntil(() => browser.PreviewBox.ShownParts.Count == 1 && browser.PreviewLoad.IsCompleted); + + Assert.True(browser.PreviewLoad.IsCompletedSuccessfully); + Assert.Equal(new[] { (plate.Id, 2), (gone.Id, 1) }, requested.OrderBy(r => r.Item1 == gone.Id)); + Assert.Equal("SYN-PLATE", browser.PreviewBox.ShownParts[0].BaseDrawing.Name); + Assert.Equal(new[] { "2 drawings, 5 parts, one scale", "1 could not be loaded." }, browser.PreviewBox.InfoLines); + + // Reading the list again keeps the shown preview and its cached drawings. + refresh = browser.RefreshAsync(); + PumpUntil(() => refresh.IsCompleted); + Run(browser.LoadSelectedPreviewAsync()); + Assert.Single(browser.PreviewBox.ShownParts); + Assert.Equal(2, requested.Count(r => r.Item1 == gone.Id) + requested.Count(r => r.Item1 == plate.Id)); + }, TimeSpan.FromMinutes(1), "The STA test did not complete."); + + private static void Run(Task task) + { + PumpUntil(() => task.IsCompleted); + task.GetAwaiter().GetResult(); + } + + private static void PumpUntil(Func condition) + { var until = DateTime.UtcNow.AddSeconds(15); - while (!load.IsCompleted && DateTime.UtcNow < until) + while (!condition() && DateTime.UtcNow < until) { System.Windows.Forms.Application.DoEvents(); Thread.Sleep(10); } - Assert.True(load.IsCompletedSuccessfully); - - Assert.Equal(new[] { (plate.Id, 2), (gone.Id, 1) }, requested.OrderBy(r => r.Item1 == gone.Id)); - Assert.Single(form.Preview.Parts); - Assert.Equal("SYN-PLATE", form.Preview.Parts[0].BaseDrawing.Name); - }, TimeSpan.FromMinutes(1), "The STA test did not complete."); + Assert.True(condition(), "Timed out waiting for the browser."); + } } diff --git a/OpenNest/Controls/DrawingSetBrowser.Designer.cs b/OpenNest/Controls/DrawingSetBrowser.Designer.cs index b46d60a3..c853452c 100644 --- a/OpenNest/Controls/DrawingSetBrowser.Designer.cs +++ b/OpenNest/Controls/DrawingSetBrowser.Designer.cs @@ -34,6 +34,11 @@ namespace OpenNest.Controls savedColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); savedByColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); stateColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + splitContainer = new System.Windows.Forms.SplitContainer(); + detailsTable = new System.Windows.Forms.TableLayoutPanel(); + previewPanel = new System.Windows.Forms.Panel(); + previewBox = new OpenNest.Controls.DrawingSetPreviewControl(); + previewTimer = new System.Windows.Forms.Timer(components); notesGroup = new System.Windows.Forms.GroupBox(); notesBox = new System.Windows.Forms.TextBox(); bottomTable = new System.Windows.Forms.TableLayoutPanel(); @@ -42,7 +47,6 @@ namespace OpenNest.Controls summaryLabel = new System.Windows.Forms.Label(); newButton = new System.Windows.Forms.Button(); editButton = new System.Windows.Forms.Button(); - previewButton = new System.Windows.Forms.Button(); moreButton = new System.Windows.Forms.Button(); releaseButton = new System.Windows.Forms.Button(); statusLabel = new System.Windows.Forms.Label(); @@ -53,13 +57,18 @@ namespace OpenNest.Controls editSeparator = new System.Windows.Forms.ToolStripSeparator(); archiveMenuItem = new System.Windows.Forms.ToolStripMenuItem(); historyMenuItem = new System.Windows.Forms.ToolStripMenuItem(); - previewMenuItem = new System.Windows.Forms.ToolStripMenuItem(); releasesMenuItem = new System.Windows.Forms.ToolStripMenuItem(); importSeparator = new System.Windows.Forms.ToolStripSeparator(); importBomMenuItem = new System.Windows.Forms.ToolStripMenuItem(); layoutTable.SuspendLayout(); filterTable.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)setGrid).BeginInit(); + ((System.ComponentModel.ISupportInitialize)splitContainer).BeginInit(); + splitContainer.Panel1.SuspendLayout(); + splitContainer.Panel2.SuspendLayout(); + splitContainer.SuspendLayout(); + detailsTable.SuspendLayout(); + previewPanel.SuspendLayout(); notesGroup.SuspendLayout(); bottomTable.SuspendLayout(); moreMenu.SuspendLayout(); @@ -70,18 +79,16 @@ namespace OpenNest.Controls 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(setGrid, 0, 1); - layoutTable.Controls.Add(notesGroup, 0, 2); - layoutTable.Controls.Add(bottomTable, 0, 3); - layoutTable.Controls.Add(statusLabel, 0, 4); + layoutTable.Controls.Add(splitContainer, 0, 1); + layoutTable.Controls.Add(bottomTable, 0, 2); + layoutTable.Controls.Add(statusLabel, 0, 3); 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(0); - layoutTable.RowCount = 5; + layoutTable.RowCount = 4; 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, 80F)); layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle()); layoutTable.RowStyles.Add(new System.Windows.Forms.RowStyle()); layoutTable.Size = new System.Drawing.Size(904, 541); @@ -202,6 +209,55 @@ namespace OpenNest.Controls stateColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.NotSortable; stateColumn.Width = 80; // + // splitContainer + // + splitContainer.Dock = System.Windows.Forms.DockStyle.Fill; + splitContainer.FixedPanel = System.Windows.Forms.FixedPanel.Panel2; + splitContainer.Margin = new System.Windows.Forms.Padding(0); + splitContainer.Name = "splitContainer"; + splitContainer.Panel1.Controls.Add(setGrid); + splitContainer.Panel2.Controls.Add(detailsTable); + splitContainer.Size = new System.Drawing.Size(904, 440); + splitContainer.SplitterDistance = 480; + splitContainer.TabIndex = 1; + // + // detailsTable + // + detailsTable.ColumnCount = 1; + detailsTable.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 100F)); + detailsTable.Controls.Add(previewPanel, 0, 0); + detailsTable.Controls.Add(notesGroup, 0, 1); + detailsTable.Dock = System.Windows.Forms.DockStyle.Fill; + detailsTable.Margin = new System.Windows.Forms.Padding(0); + detailsTable.Name = "detailsTable"; + detailsTable.Padding = new System.Windows.Forms.Padding(6, 0, 0, 0); + detailsTable.RowCount = 2; + detailsTable.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 100F)); + detailsTable.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Absolute, 104F)); + detailsTable.TabIndex = 0; + // + // previewPanel + // + previewPanel.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + previewPanel.Controls.Add(previewBox); + previewPanel.Dock = System.Windows.Forms.DockStyle.Fill; + previewPanel.Margin = new System.Windows.Forms.Padding(0, 0, 0, 6); + previewPanel.MinimumSize = new System.Drawing.Size(0, 160); + previewPanel.Name = "previewPanel"; + previewPanel.TabIndex = 0; + // + // previewBox + // + previewBox.Dock = System.Windows.Forms.DockStyle.Fill; + previewBox.Name = "previewBox"; + previewBox.TabIndex = 0; + previewBox.TabStop = false; + // + // previewTimer + // + previewTimer.Interval = 150; + previewTimer.Tick += PreviewTimer_Tick; + // // notesGroup // notesGroup.Controls.Add(notesBox); @@ -225,7 +281,7 @@ namespace OpenNest.Controls // bottomTable // bottomTable.AutoSize = true; - bottomTable.ColumnCount = 8; + 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)); @@ -233,15 +289,13 @@ namespace OpenNest.Controls 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(newButton, 3, 0); bottomTable.Controls.Add(editButton, 4, 0); - bottomTable.Controls.Add(previewButton, 5, 0); - bottomTable.Controls.Add(moreButton, 6, 0); - bottomTable.Controls.Add(releaseButton, 7, 0); + bottomTable.Controls.Add(moreButton, 5, 0); + bottomTable.Controls.Add(releaseButton, 6, 0); bottomTable.Dock = System.Windows.Forms.DockStyle.Fill; bottomTable.Margin = new System.Windows.Forms.Padding(0, 6, 0, 0); bottomTable.Name = "bottomTable"; @@ -294,22 +348,12 @@ namespace OpenNest.Controls editButton.UseVisualStyleBackColor = true; editButton.Click += EditButton_Click; // - // previewButton - // - previewButton.AutoSize = true; - previewButton.Enabled = false; - previewButton.Name = "previewButton"; - previewButton.TabIndex = 5; - previewButton.Text = "&Preview..."; - previewButton.UseVisualStyleBackColor = true; - previewButton.Click += PreviewButton_Click; - // // moreButton // moreButton.AutoSize = true; moreButton.Enabled = false; moreButton.Name = "moreButton"; - moreButton.TabIndex = 6; + moreButton.TabIndex = 5; moreButton.Text = "M&ore ▾"; moreButton.UseVisualStyleBackColor = true; moreButton.Click += MoreButton_Click; @@ -321,7 +365,7 @@ namespace OpenNest.Controls releaseButton.Font = new System.Drawing.Font("Segoe UI", 9.75F, System.Drawing.FontStyle.Bold); releaseButton.Margin = new System.Windows.Forms.Padding(12, 3, 3, 3); releaseButton.Name = "releaseButton"; - releaseButton.TabIndex = 7; + releaseButton.TabIndex = 6; releaseButton.Text = "&Add to Release"; releaseButton.UseVisualStyleBackColor = true; releaseButton.Visible = false; @@ -343,7 +387,7 @@ namespace OpenNest.Controls // // moreMenu // - moreMenu.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { addToReleaseMenuItem, editMenuItem, previewMenuItem, editSeparator, archiveMenuItem, historyMenuItem, releasesMenuItem, importSeparator, importBomMenuItem }); + moreMenu.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { addToReleaseMenuItem, editMenuItem, editSeparator, archiveMenuItem, historyMenuItem, releasesMenuItem, importSeparator, importBomMenuItem }); moreMenu.Name = "moreMenu"; moreMenu.Opening += MoreMenu_Opening; // @@ -366,13 +410,6 @@ namespace OpenNest.Controls // editSeparator.Name = "editSeparator"; // - // previewMenuItem - // - previewMenuItem.Enabled = false; - previewMenuItem.Name = "previewMenuItem"; - previewMenuItem.Text = "&Preview..."; - previewMenuItem.Click += PreviewButton_Click; - // // archiveMenuItem // archiveMenuItem.Enabled = false; @@ -417,6 +454,12 @@ namespace OpenNest.Controls filterTable.ResumeLayout(false); filterTable.PerformLayout(); ((System.ComponentModel.ISupportInitialize)setGrid).EndInit(); + splitContainer.Panel1.ResumeLayout(false); + splitContainer.Panel2.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)splitContainer).EndInit(); + splitContainer.ResumeLayout(false); + detailsTable.ResumeLayout(false); + previewPanel.ResumeLayout(false); notesGroup.ResumeLayout(false); notesGroup.PerformLayout(); bottomTable.ResumeLayout(false); @@ -440,6 +483,11 @@ namespace OpenNest.Controls private System.Windows.Forms.DataGridViewTextBoxColumn savedColumn; private System.Windows.Forms.DataGridViewTextBoxColumn savedByColumn; private System.Windows.Forms.DataGridViewTextBoxColumn stateColumn; + private System.Windows.Forms.SplitContainer splitContainer; + private System.Windows.Forms.TableLayoutPanel detailsTable; + private System.Windows.Forms.Panel previewPanel; + private OpenNest.Controls.DrawingSetPreviewControl previewBox; + private System.Windows.Forms.Timer previewTimer; private System.Windows.Forms.GroupBox notesGroup; private System.Windows.Forms.TextBox notesBox; private System.Windows.Forms.TableLayoutPanel bottomTable; @@ -448,7 +496,6 @@ namespace OpenNest.Controls private System.Windows.Forms.Label summaryLabel; private System.Windows.Forms.Button newButton; private System.Windows.Forms.Button editButton; - private System.Windows.Forms.Button previewButton; private System.Windows.Forms.Button moreButton; private System.Windows.Forms.Button releaseButton; private System.Windows.Forms.Label statusLabel; @@ -459,7 +506,6 @@ namespace OpenNest.Controls private System.Windows.Forms.ToolStripSeparator editSeparator; private System.Windows.Forms.ToolStripMenuItem archiveMenuItem; private System.Windows.Forms.ToolStripMenuItem historyMenuItem; - private System.Windows.Forms.ToolStripMenuItem previewMenuItem; private System.Windows.Forms.ToolStripMenuItem releasesMenuItem; private System.Windows.Forms.ToolStripSeparator importSeparator; private System.Windows.Forms.ToolStripMenuItem importBomMenuItem; diff --git a/OpenNest/Controls/DrawingSetBrowser.cs b/OpenNest/Controls/DrawingSetBrowser.cs index 07558d31..1444f4c4 100644 --- a/OpenNest/Controls/DrawingSetBrowser.cs +++ b/OpenNest/Controls/DrawingSetBrowser.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using OpenNest.Data; @@ -20,15 +21,25 @@ namespace OpenNest.Controls /// shows their history. hosts it on its Sets tab beside its /// , and shows a set's Releases on its Releases tab. Once release is /// enabled, a double-click or Enter adds the set to the release (Add to Release does so for - /// every selected set); Edit opens the set editor. New Set, Edit, Preview and Add to Release - /// are buttons; Add to Release, Edit Set, Preview, Archive/Restore, History and Releases, then - /// Import BOM, are in the More menu, which is also the list's right-click menu. Preview shows - /// all of a set's drawings in one window at the same scale (). In pick mode + /// every selected set); Edit opens the set editor. New Set, Edit and Add to Release are + /// buttons; Add to Release, Edit Set, Archive/Restore, History and Releases, then Import BOM, + /// are in the More menu, which is also the list's right-click menu. The details pane beside + /// the list shows the highlighted set's notes and all of its drawings at one scale + /// (), read shortly after the selection settles; drawings + /// are cached per revision for the control's lifetime. In pick mode /// () only active sets are listed, and choosing a set or /// New Set raises or instead. /// public partial class DrawingSetBrowser : UserControl { + private const int DetailsWidth = 420; + private const int PreviewCacheLimit = 256; + private const int PreviewConcurrency = 4; + + private readonly Dictionary<(Guid Id, int Revision), Drawing> previewDrawings = new(); + private CancellationTokenSource previewCancel; + private (Guid Id, int Revision)? previewShown; + private bool detailsFitted; private IDrawingSetStore sets; private IDrawingLibraryStore library; private string serverUrl; @@ -55,8 +66,16 @@ namespace OpenNest.Controls /// internal Func ImportBom { get; set; } - /// Shows the same-scale preview of a set read with its members; replaced by tests. - internal Action ShowPreview { get; set; } + /// + /// Reads the drawing a record's package holds for the preview, or null when the + /// revision is gone; replaced by tests. + /// + internal Func> LoadPreviewDrawing { get; set; } + + /// The preview read in flight or last finished; for tests. + internal Task PreviewLoad { get; private set; } = Task.CompletedTask; + + internal DrawingSetPreviewControl PreviewBox => previewBox; /// Adds a set read with its members to the release; set by . internal Action AddToRelease { get; private set; } @@ -78,6 +97,31 @@ namespace OpenNest.Controls customerColumn.Tag = DrawingSetSortField.Customer; revisionColumn.Tag = DrawingSetSortField.Revision; savedColumn.Tag = DrawingSetSortField.SavedAt; + previewBox.SetInfo("Select a set to preview its drawings."); + Load += (_, _) => FitDetailsPane(); + // Until the first fit takes, every resize retries it; after that the split is the user's. + splitContainer.Resize += (_, _) => + { + if (!detailsFitted && IsHandleCreated) + FitDetailsPane(); + }; + Disposed += (_, _) => previewCancel?.Cancel(); + } + + // The list takes the spare width; the details pane keeps its width as the window resizes. + // A container too narrow for both minimums (a tab not yet laid out) is left alone, since + // the split container refuses minimums it cannot honor. + internal void FitDetailsPane() + { + const int listMin = 320; + const int detailsMin = 300; + var available = splitContainer.Width - splitContainer.SplitterWidth; + if (available < listMin + detailsMin) + return; + splitContainer.Panel1MinSize = listMin; + splitContainer.SplitterDistance = System.Math.Clamp(available - DetailsWidth, listMin, available - detailsMin); + splitContainer.Panel2MinSize = detailsMin; + detailsFitted = true; } /// True once has run; the first loads the list. @@ -135,14 +179,10 @@ namespace OpenNest.Controls return form.ShowDialog(FindForm()) == DialogResult.OK ? form.Edit : null; }; setGrid.ContextMenuStrip = pick ? null : moreMenu; - ShowPreview = set => + LoadPreviewDrawing = async (record, token) => { - using var form = new DrawingSetPreviewForm(set, async (record, token) => - { - var bytes = await library.GetRevisionPackageAsync(record.Id, record.Revision, token); - return bytes == null ? null : await Task.Run(() => DrawingPackage.Read(bytes).Drawing, token); - }); - form.ShowDialog(FindForm()); + var bytes = await library.GetRevisionPackageAsync(record.Id, record.Revision, token); + return bytes == null ? null : await Task.Run(() => DrawingPackage.Read(bytes).Drawing, token); }; setGrid.MultiSelect = !pick; // Add to Release acts on every selected set. @@ -153,7 +193,6 @@ namespace OpenNest.Controls newButton.Text = "New Set"; showArchivedCheckBox.Visible = false; editButton.Visible = false; - previewButton.Visible = false; moreButton.Visible = false; } @@ -217,10 +256,134 @@ namespace OpenNest.Controls private void SetGrid_CurrentCellChanged(object sender, EventArgs e) { notesBox.Text = SelectedSet?.Notes ?? ""; + SchedulePreview(); UpdateCommands(); SelectedSetChanged?.Invoke(this, EventArgs.Empty); } + /// + /// Clears the preview for a new highlighted set and starts the short timer that reads it, + /// so moving through the list does not read every set passed over. + /// + private void SchedulePreview() + { + var set = SelectedSet; + if (set != null && previewShown == (set.Id, set.Revision)) + return; + + previewTimer.Stop(); + previewCancel?.Cancel(); + previewShown = null; + previewBox.ShowDrawings(Array.Empty<(Drawing, string)>()); + if (session == null || set == null) + previewBox.SetInfo("Select a set to preview its drawings."); + else if (set.MemberCount == 0) + previewBox.SetInfo("This set has no drawings."); + else + { + previewBox.SetInfo("Loading preview..."); + previewTimer.Start(); + } + } + + private void PreviewTimer_Tick(object sender, EventArgs e) => PreviewLoad = LoadSelectedPreviewAsync(); + + /// + /// Reads the highlighted set with its members, then each member's current drawing (cached + /// per revision), and shows them all at one scale. A newer selection cancels it; a + /// failure only shows in the preview, as the preview is best-effort. + /// + internal async Task LoadSelectedPreviewAsync() + { + previewTimer.Stop(); + var listed = SelectedSet; + if (session == null || listed == null || listed.MemberCount == 0) + return; + + previewCancel?.Cancel(); + var cancel = previewCancel = new CancellationTokenSource(); + var token = cancel.Token; + DrawingSetRecord set; + Drawing[] drawings; + try + { + set = await sets.GetAsync(listed.Id, token); + drawings = set == null ? null : await LoadPreviewDrawingsAsync(set.Members, token); + } + catch (Exception) when (token.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + if (cancel == previewCancel && !IsDisposed) + previewBox.SetInfo("Preview unavailable.", ex.Message); + return; + } + + if (IsDisposed || cancel != previewCancel) + return; + if (set == null) + { + previewBox.SetInfo("This set is no longer on the server."); + return; + } + + var members = set.Members; + previewBox.ShowDrawings(members.Select((member, i) => (drawings[i], $"{member.Drawing?.Name} ×{member.Quantity}"))); + previewBox.SetInfo(PreviewInfo(members, drawings).ToArray()); + previewShown = (listed.Id, listed.Revision); + } + + private async Task LoadPreviewDrawingsAsync(IReadOnlyList members, CancellationToken token) + { + var drawings = new Drawing[members.Count]; + using var gate = new SemaphoreSlim(PreviewConcurrency); + await Task.WhenAll(members.Select(async (member, i) => + { + if (member.Drawing is not { } record) + return; + var key = (record.Id, record.Revision); + if (previewDrawings.TryGetValue(key, out var cached)) + { + drawings[i] = cached; + return; + } + + await gate.WaitAsync(token); + try + { + drawings[i] = await LoadPreviewDrawing(record, token); + } + catch (Exception) when (!token.IsCancellationRequested) + { + // One drawing that fails to load is counted as missing; the rest still show. + return; + } + finally + { + gate.Release(); + } + + // Continuations resume on the UI thread, so the cache needs no lock. + if (previewDrawings.Count >= PreviewCacheLimit) + previewDrawings.Clear(); + previewDrawings[key] = drawings[i]; + })); + return drawings; + } + + private static IEnumerable PreviewInfo(IReadOnlyList members, Drawing[] drawings) + { + var parts = members.Sum(m => m.Quantity); + yield return $"{members.Count} drawing{(members.Count == 1 ? "" : "s")}, {parts} part{(parts == 1 ? "" : "s")}, one scale"; + if (members.Where(m => m.Drawing != null).Select(m => m.Drawing.Units).Distinct().Count() > 1) + yield return "Mixes inches and mm: those are not to scale."; + var missing = drawings.Count(d => !DrawingSetPreviewControl.CanShow(d)); + if (missing > 0) + yield return $"{missing} could not be loaded."; + } + private async void SetGrid_CellDoubleClick(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex < 0) @@ -269,8 +432,6 @@ namespace OpenNest.Controls private async void EditButton_Click(object sender, EventArgs e) => await EditSelectedAsync(); - private async void PreviewButton_Click(object sender, EventArgs e) => await PreviewSelectedAsync(); - private async void ArchiveMenuItem_Click(object sender, EventArgs e) => await ToggleArchivedAsync(); private void HistoryMenuItem_Click(object sender, EventArgs e) @@ -370,49 +531,6 @@ namespace OpenNest.Controls } } - /// - /// Reads the selected set (archived or not) with its members' current drawings and - /// shows them all in one window at the same scale. - /// - internal async Task PreviewSelectedAsync() - { - var listed = SelectedSet; - if (session == null || listed == null || busy || pick) - return; - - DrawingSetRecord current; - busy = true; - UpdateCommands(); - statusLabel.Text = $"Reading set '{listed.Name}'..."; - try - { - current = await sets.GetAsync(listed.Id); - } - catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException) - { - Fail($"Could not read set '{listed.Name}': {ex.Message}"); - return; - } - finally - { - busy = false; - if (!IsDisposed) - UpdateCommands(); - } - - if (IsDisposed) - return; - statusLabel.Text = ""; - if (current == null) - { - ShowMessage($"Set '{listed.Name}' no longer exists on the server.", MessageBoxButtons.OK, MessageBoxIcon.Warning); - await RunAsync(session.RefreshAsync); - return; - } - - ShowPreview(current); - } - private void ActivateSelected() { if (SelectedSet is { Archived: false }) @@ -577,13 +695,12 @@ namespace OpenNest.Controls editButton.Enabled = ready && selected is { Archived: false }; editMenuItem.Enabled = editButton.Enabled; editMenuItem.Visible = editSeparator.Visible = !pick; - previewButton.Enabled = previewMenuItem.Enabled = ready && !pick && selected is { MemberCount: > 0 }; archiveMenuItem.Enabled = ready && selected != null; archiveMenuItem.Text = selected is { Archived: true } ? "&Restore" : "&Archive"; historyMenuItem.Enabled = ready && selected != null; releasesMenuItem.Enabled = ready && selected != null && ShowReleases != null; importBomMenuItem.Enabled = ready && !pick; - moreButton.Enabled = previewMenuItem.Enabled || archiveMenuItem.Enabled || historyMenuItem.Enabled || releasesMenuItem.Enabled + moreButton.Enabled = archiveMenuItem.Enabled || historyMenuItem.Enabled || releasesMenuItem.Enabled || importBomMenuItem.Enabled; releaseButton.Enabled = ready && AddToRelease != null && SelectedSets().Any(s => !s.Archived && s.MemberCount > 0); diff --git a/OpenNest/Controls/DrawingSetPreviewControl.cs b/OpenNest/Controls/DrawingSetPreviewControl.cs index 19260133..68647201 100644 --- a/OpenNest/Controls/DrawingSetPreviewControl.cs +++ b/OpenNest/Controls/DrawingSetPreviewControl.cs @@ -8,10 +8,10 @@ using OpenNest.Geometry; namespace OpenNest.Controls { /// - /// Shows every drawing of a set side by side at one scale, packed by - /// with a label under each. The layout is repacked - /// to fit whenever the control is resized; the wheel zooms, the middle button pans and a - /// middle double-click fits the set again. + /// Shows every drawing of a set side by side at one scale, outlined without fill, packed by + /// with a label under each and the info lines above. + /// The layout is repacked to fit whenever the control is resized; the wheel zooms, the + /// middle button pans and a middle double-click fits the set again. /// public class DrawingSetPreviewControl : PlateView { @@ -19,13 +19,12 @@ namespace OpenNest.Controls private readonly List<(Part Part, string Label)> items = new(); private DrawingSetPreviewLayout.Result layout; - private string[] infoLines; + private string[] infoLines = System.Array.Empty(); public DrawingSetPreviewControl() { DrawOrigin = false; DrawBounds = false; - DrawPartNumbers = false; AllowPan = true; AllowSelect = false; AllowZoom = true; @@ -35,15 +34,25 @@ namespace OpenNest.Controls ViewScaleMin = 1e-5f; } + /// False for a drawing with no geometry to show; skips it. + public static bool CanShow(Drawing drawing) => drawing?.Program?.Codes.Count > 0; + /// The placed parts, in the order they were given. - internal IReadOnlyList Parts => items.Select(i => i.Part).ToList(); + internal IReadOnlyList ShownParts => items.Select(i => i.Part).ToList(); - internal DrawingSetPreviewLayout.Result Layout => layout; + internal DrawingSetPreviewLayout.Result PackedLayout => layout; + internal IReadOnlyList InfoLines => infoLines; + + /// Lines shown above the drawings; the drawings are fitted below them. public void SetInfo(params string[] lines) { - infoLines = lines; - Invalidate(); + var before = InfoHeight; + infoLines = lines ?? System.Array.Empty(); + if (InfoHeight != before && items.Count > 0) + Arrange(); + else + Invalidate(); } /// Replaces the shown drawings; each is labeled with its text. @@ -66,9 +75,6 @@ namespace OpenNest.Controls Arrange(); } - /// False for a drawing with no geometry to show; skips it. - public static bool CanShow(Drawing drawing) => drawing?.Program?.Codes.Count > 0; - public override void ZoomToFit(bool redraw = true) { if (layout == null) @@ -78,7 +84,14 @@ namespace OpenNest.Controls return; } - ZoomToArea(0, -layout.Height, layout.Width, layout.Height, redraw); + // Centers the layout below the info lines; world Y 0 is the layout's top edge. + var top = InfoHeight; + ViewScale = (float)layout.Scale; + origin.X = (Width - LengthWorldToGui(layout.Width)) * 0.5f; + origin.Y = top + (Height - top - LengthWorldToGui(layout.Height)) * 0.5f; + UpdateMatrix(); + if (redraw) + Invalidate(); } protected override void OnResize(System.EventArgs e) @@ -88,17 +101,21 @@ namespace OpenNest.Controls Arrange(); } + private int InfoHeight => infoLines.Count(l => !string.IsNullOrEmpty(l)) * (Font.Height + 1) + 4; + private void Arrange() { - if (items.Count == 0 || Width <= BorderWidth || Height <= BorderWidth) + var top = InfoHeight; + if (items.Count == 0 || Width <= BorderWidth || Height <= BorderWidth + top) { + layout = null; Invalidate(); return; } var sizes = items.Select(i => (i.Part.BoundingBox.Length, i.Part.BoundingBox.Width)).ToList(); var labelPixels = Font.Height + 2; - layout = DrawingSetPreviewLayout.Arrange(sizes, Width - BorderWidth, Height - BorderWidth, labelPixels, MinCellPixels); + layout = DrawingSetPreviewLayout.Arrange(sizes, Width - BorderWidth, Height - BorderWidth - top, labelPixels, MinCellPixels); foreach (var placement in layout.Placements) { @@ -119,14 +136,28 @@ namespace OpenNest.Controls e.Graphics.SmoothingMode = SmoothingMode.HighQuality; e.Graphics.TranslateTransform(origin.X, origin.Y); - Renderer.DrawPlate(e.Graphics); - Renderer.DrawParts(e.Graphics); + PaintOutlines(e.Graphics); PaintLabels(e.Graphics); e.Graphics.ResetTransform(); PaintInfo(e.Graphics); } + // Outlines only: cuts in black, etch marks dashed in gray. + private void PaintOutlines(Graphics g) + { + using var etchPen = new Pen(Color.Gray) { DashStyle = DashStyle.Dash }; + foreach (var part in LayoutParts) + { + if (part.IsDirty) + part.Update(this); + if (part.EtchPath?.PointCount > 0) + g.DrawPath(etchPen, part.EtchPath); + if (part.Path?.PointCount > 0) + g.DrawPath(Pens.Black, part.Path); + } + } + private void PaintLabels(Graphics g) { if (layout == null) @@ -151,17 +182,19 @@ namespace OpenNest.Controls private void PaintInfo(Graphics g) { - if (infoLines == null) - return; - - var lineHeight = Font.GetHeight(g) + 1; + var lineHeight = Font.Height + 1; var y = 4f; + using var format = new StringFormat + { + Trimming = StringTrimming.EllipsisCharacter, + FormatFlags = StringFormatFlags.NoWrap, + }; foreach (var line in infoLines) { if (string.IsNullOrEmpty(line)) continue; - g.DrawString(line, Font, Brushes.Black, 4, y); + g.DrawString(line, Font, Brushes.Black, new RectangleF(4, y, System.Math.Max(Width - 8, 1), lineHeight), format); y += lineHeight; } } diff --git a/OpenNest/Controls/PlateRenderer.cs b/OpenNest/Controls/PlateRenderer.cs index 50659701..bd26100c 100644 --- a/OpenNest/Controls/PlateRenderer.cs +++ b/OpenNest/Controls/PlateRenderer.cs @@ -127,7 +127,7 @@ namespace OpenNest.Controls || view.MutedParts?.Contains(part.BasePart) == true; var number = view.PreviewSequence != null && view.PreviewSequence.TryGetValue(part.BasePart, out var preview) ? preview : i + 1; - part.Draw(g, view.DrawPartNumbers ? number.ToString() : null, muted); + part.Draw(g, number.ToString(), muted); DrawBendLines(g, part.BasePart, muted); DrawEtchMarks(g, part.BasePart, muted); DrawGrainWarning(g, part.BasePart, muted); diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index bbafa244..1c66e844 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -191,9 +191,6 @@ namespace OpenNest.Controls public bool DrawBounds { get; set; } - /// Numbers each part with its position in the plate's sequence. - public bool DrawPartNumbers { get; set; } = true; - public bool DrawOffset { get; set; } public bool DrawCutDirection { get; set; } diff --git a/OpenNest/Forms/DrawingSetPreviewForm.cs b/OpenNest/Forms/DrawingSetPreviewForm.cs deleted file mode 100644 index 3d0351c2..00000000 --- a/OpenNest/Forms/DrawingSetPreviewForm.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Drawing; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using System.Windows.Forms; -using OpenNest.Controls; -using OpenNest.Data; - -namespace OpenNest.Forms -{ - /// - /// Shows every drawing of a set in one , all at the - /// same scale, labeled with its name and the set's quantity. Each member's current - /// revision is downloaded when the window is shown; members that are gone or fail to - /// load are counted in the header and left out. Closing cancels the downloads. - /// - public class DrawingSetPreviewForm : Form - { - private const int Concurrency = 4; - - private readonly DrawingSetRecord set; - private readonly Func> load; - private readonly CancellationTokenSource cancel = new(); - private readonly DrawingSetPreviewControl preview; - - /// The set read with its members' current drawings. - /// Reads the drawing a record's package holds, or null when the revision is gone. - public DrawingSetPreviewForm(DrawingSetRecord set, Func> load) - { - this.set = set ?? throw new ArgumentNullException(nameof(set)); - this.load = load ?? throw new ArgumentNullException(nameof(load)); - - preview = new DrawingSetPreviewControl { Dock = DockStyle.Fill }; - var closeButton = new Button - { - Text = "Close", - DialogResult = DialogResult.Cancel, - AutoSize = true, - Anchor = AnchorStyles.Right, - }; - var bottom = new FlowLayoutPanel - { - Dock = DockStyle.Bottom, - AutoSize = true, - FlowDirection = FlowDirection.RightToLeft, - Padding = new Padding(6), - }; - bottom.Controls.Add(closeButton); - Controls.Add(preview); - Controls.Add(bottom); - - CancelButton = closeButton; - Font = new Font("Segoe UI", 9.75F); - Text = $"Preview — {set.Name} (revision {set.Revision})"; - StartPosition = FormStartPosition.CenterParent; - Size = new System.Drawing.Size(1100, 800); - MinimumSize = new System.Drawing.Size(400, 300); - ShowIcon = false; - ShowInTaskbar = false; - } - - internal DrawingSetPreviewControl Preview => preview; - - /// Completes once every member has been read and shown; for tests. - internal Task Loaded { get; private set; } = Task.CompletedTask; - - protected override void OnShown(EventArgs e) - { - base.OnShown(e); - Loaded = LoadAsync(); - } - - protected override void OnFormClosed(FormClosedEventArgs e) - { - cancel.Cancel(); - base.OnFormClosed(e); - } - - protected override void Dispose(bool disposing) - { - if (disposing) - cancel.Dispose(); - base.Dispose(disposing); - } - - internal async Task LoadAsync() - { - var members = set.Members; - if (members.Count == 0) - { - preview.SetInfo("This set has no drawings."); - return; - } - - var token = cancel.Token; - var drawings = new Drawing[members.Count]; - var failed = 0; - preview.SetInfo($"Loading {members.Count} drawings..."); - - using var gate = new SemaphoreSlim(Concurrency); - var reads = members.Select(async (member, i) => - { - if (member.Drawing == null) - { - Interlocked.Increment(ref failed); - return; - } - - await gate.WaitAsync(token); - try - { - drawings[i] = await load(member.Drawing, token); - if (!DrawingSetPreviewControl.CanShow(drawings[i])) - Interlocked.Increment(ref failed); - } - catch (Exception) when (!token.IsCancellationRequested) - { - // The preview is best-effort: a drawing that fails to load is counted and skipped. - Interlocked.Increment(ref failed); - } - finally - { - gate.Release(); - } - }).ToList(); - - try - { - await Task.WhenAll(reads); - } - catch (OperationCanceledException) - { - return; - } - - if (IsDisposed || token.IsCancellationRequested) - return; - - preview.ShowDrawings(members.Select((member, i) => (drawings[i], Label(member)))); - preview.SetInfo(Header(members, failed).ToArray()); - } - - private static string Label(DrawingSetMember member) => - $"{member.Drawing?.Name} ×{member.Quantity}"; - - private IEnumerable Header(IReadOnlyList members, int failed) - { - var parts = members.Sum(m => m.Quantity); - yield return $"{members.Count} drawings, {parts} parts — all shown at the same scale"; - - var units = members.Where(m => m.Drawing != null).Select(m => m.Drawing.Units).Distinct().ToList(); - if (units.Count > 1) - yield return "This set mixes inches and millimeters; drawings in different units are not to scale with each other."; - if (failed > 0) - yield return $"{failed} drawing{(failed == 1 ? "" : "s")} could not be loaded and {(failed == 1 ? "is" : "are")} not shown."; - } - } -} diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs index 66dd0a42..9df6dd7f 100644 --- a/OpenNest/LayoutPart.cs +++ b/OpenNest/LayoutPart.cs @@ -118,8 +118,6 @@ namespace OpenNest internal void Draw(Graphics g, string id, bool muted) { Draw(g, muted); - if (id == null) - return; using var sf = new StringFormat {