From cbf3b728aa958a2d5ee6c5754fe591a73ec8d97d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 24 Oct 2026 22:35:24 -0400 Subject: [PATCH] feat(desktop): preview the highlighted set beside the Sets list The set preview moves from its own window into a details pane beside the list, as the Drawing Library has, with the set's notes below it. It reads the highlighted set shortly after the selection settles and caches drawings per revision. Parts are drawn as outlines without fill, and the info lines sit above the fitted drawings instead of over them. Co-Authored-By: Claude Opus 5.5 --- .../Controls/DrawingSetPreviewTests.cs | 72 ++++-- .../Controls/DrawingSetBrowser.Designer.cs | 116 ++++++--- OpenNest/Controls/DrawingSetBrowser.cs | 239 +++++++++++++----- OpenNest/Controls/DrawingSetPreviewControl.cs | 79 ++++-- OpenNest/Controls/PlateRenderer.cs | 2 +- OpenNest/Controls/PlateView.cs | 3 - OpenNest/Forms/DrawingSetPreviewForm.cs | 160 ------------ OpenNest/LayoutPart.cs | 2 - 8 files changed, 366 insertions(+), 307 deletions(-) delete mode 100644 OpenNest/Forms/DrawingSetPreviewForm.cs 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 {