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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-24 22:35:29 -04:00
1 parent fa70713bfc
commit cbf3b728aa
8 files changed
+366 -307

No files matched your search

@@ -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<bool> 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.");
}
}
+81 -35
View File
@@ -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;
+178 -61
View File
@@ -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. <see cref="DrawingSetsForm"/> hosts it on its Sets tab beside its
/// <see cref="ReleasePanel"/>, 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 (<see cref="DrawingSetPreviewForm"/>). 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
/// (<see cref="DrawingSetPreviewControl"/>), read shortly after the selection settles; drawings
/// are cached per revision for the control's lifetime. In pick mode
/// (<see cref="ChooseDrawingSetForm"/>) only active sets are listed, and choosing a set or
/// New Set raises <see cref="SetActivated"/> or <see cref="NewSetChosen"/> instead.
/// </summary>
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
/// </summary>
internal Func<DrawingSetEditSession> ImportBom { get; set; }
/// <summary>Shows the same-scale preview of a set read with its members; replaced by tests.</summary>
internal Action<DrawingSetRecord> ShowPreview { get; set; }
/// <summary>
/// Reads the drawing a record's package holds for the preview, or null when the
/// revision is gone; replaced by tests.
/// </summary>
internal Func<DrawingLibraryRecord, CancellationToken, Task<Drawing>> LoadPreviewDrawing { get; set; }
/// <summary>The preview read in flight or last finished; for tests.</summary>
internal Task PreviewLoad { get; private set; } = Task.CompletedTask;
internal DrawingSetPreviewControl PreviewBox => previewBox;
/// <summary>Adds a set read with its members to the release; set by <see cref="EnableRelease"/>.</summary>
internal Action<DrawingSetRecord> 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;
}
/// <summary>True once <see cref="Initialize"/> has run; the first <see cref="RefreshAsync"/> loads the list.</summary>
@@ -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);
}
/// <summary>
/// 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.
/// </summary>
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();
/// <summary>
/// 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.
/// </summary>
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<Drawing[]> LoadPreviewDrawingsAsync(IReadOnlyList<DrawingSetMember> 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<string> PreviewInfo(IReadOnlyList<DrawingSetMember> 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
}
}
/// <summary>
/// Reads the selected set (archived or not) with its members' current drawings and
/// shows them all in one window at the same scale.
/// </summary>
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);
+56 -23
View File
@@ -8,10 +8,10 @@ using OpenNest.Geometry;
namespace OpenNest.Controls
{
/// <summary>
/// Shows every drawing of a set side by side at one scale, packed by
/// <see cref="DrawingSetPreviewLayout"/> 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
/// <see cref="DrawingSetPreviewLayout"/> 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.
/// </summary>
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<string>();
public DrawingSetPreviewControl()
{
DrawOrigin = false;
DrawBounds = false;
DrawPartNumbers = false;
AllowPan = true;
AllowSelect = false;
AllowZoom = true;
@@ -35,15 +34,25 @@ namespace OpenNest.Controls
ViewScaleMin = 1e-5f;
}
/// <summary>False for a drawing with no geometry to show; <see cref="ShowDrawings"/> skips it.</summary>
public static bool CanShow(Drawing drawing) => drawing?.Program?.Codes.Count > 0;
/// <summary>The placed parts, in the order they were given.</summary>
internal IReadOnlyList<Part> Parts => items.Select(i => i.Part).ToList();
internal IReadOnlyList<Part> ShownParts => items.Select(i => i.Part).ToList();
internal DrawingSetPreviewLayout.Result Layout => layout;
internal DrawingSetPreviewLayout.Result PackedLayout => layout;
internal IReadOnlyList<string> InfoLines => infoLines;
/// <summary>Lines shown above the drawings; the drawings are fitted below them.</summary>
public void SetInfo(params string[] lines)
{
infoLines = lines;
Invalidate();
var before = InfoHeight;
infoLines = lines ?? System.Array.Empty<string>();
if (InfoHeight != before && items.Count > 0)
Arrange();
else
Invalidate();
}
/// <summary>Replaces the shown drawings; each is labeled with its text.</summary>
@@ -66,9 +75,6 @@ namespace OpenNest.Controls
Arrange();
}
/// <summary>False for a drawing with no geometry to show; <see cref="ShowDrawings"/> skips it.</summary>
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;
}
}
+1 -1
View File
@@ -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);
-3
View File
@@ -191,9 +191,6 @@ namespace OpenNest.Controls
public bool DrawBounds { get; set; }
/// <summary>Numbers each part with its position in the plate's sequence.</summary>
public bool DrawPartNumbers { get; set; } = true;
public bool DrawOffset { get; set; }
public bool DrawCutDirection { get; set; }
-160
View File
@@ -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
{
/// <summary>
/// Shows every drawing of a set in one <see cref="DrawingSetPreviewControl"/>, 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.
/// </summary>
public class DrawingSetPreviewForm : Form
{
private const int Concurrency = 4;
private readonly DrawingSetRecord set;
private readonly Func<DrawingLibraryRecord, CancellationToken, Task<Drawing>> load;
private readonly CancellationTokenSource cancel = new();
private readonly DrawingSetPreviewControl preview;
/// <param name="set">The set read with its members' current drawings.</param>
/// <param name="load">Reads the drawing a record's package holds, or null when the revision is gone.</param>
public DrawingSetPreviewForm(DrawingSetRecord set, Func<DrawingLibraryRecord, CancellationToken, Task<Drawing>> 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;
/// <summary>Completes once every member has been read and shown; for tests.</summary>
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<string> Header(IReadOnlyList<DrawingSetMember> 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.";
}
}
}
-2
View File
@@ -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
{