diff --git a/OpenNest.Data/NestDetails.cs b/OpenNest.Data/NestDetails.cs
index 6af06a4..b726823 100644
--- a/OpenNest.Data/NestDetails.cs
+++ b/OpenNest.Data/NestDetails.cs
@@ -17,6 +17,12 @@ public sealed class NestDetails
/// One row per drawing (cutoffs excluded), in nest order.
public IReadOnlyList Drawings { get; init; } = Array.Empty();
+ ///
+ /// The plates themselves, in the same order as , for the plate
+ /// preview. They belong to the downloaded copy of the nest, never to an open document.
+ ///
+ public IReadOnlyList PlateLayouts { get; init; } = Array.Empty();
+
public static NestDetails FromNest(Nest nest)
{
ArgumentNullException.ThrowIfNull(nest);
@@ -52,7 +58,13 @@ public sealed class NestDetails
Area: drawing.Area))
.ToList();
- return new NestDetails { Units = nest.Units, Plates = plates, Drawings = drawings };
+ return new NestDetails
+ {
+ Units = nest.Units,
+ Plates = plates,
+ Drawings = drawings,
+ PlateLayouts = nest.Plates.ToList(),
+ };
}
}
diff --git a/OpenNest.Tests/Data/NestDetailsTests.cs b/OpenNest.Tests/Data/NestDetailsTests.cs
index 1a27559..d93c021 100644
--- a/OpenNest.Tests/Data/NestDetailsTests.cs
+++ b/OpenNest.Tests/Data/NestDetailsTests.cs
@@ -54,6 +54,23 @@ public class NestDetailsTests
});
}
+ [Fact]
+ public void FromNest_KeepsEachPlateForThePreview_InNestOrder()
+ {
+ var nest = new Nest("Job");
+ var first = nest.CreatePlate();
+ var second = nest.CreatePlate();
+ var third = nest.CreatePlate();
+
+ var details = NestDetails.FromNest(nest);
+
+ Assert.Collection(
+ details.PlateLayouts,
+ plate => Assert.Same(first, plate),
+ plate => Assert.Same(second, plate),
+ plate => Assert.Same(third, plate));
+ }
+
[Fact]
public void FromNest_CountsNestedDrawingsAcrossPlateDuplicates_AndSkipsCutoffs()
{
diff --git a/OpenNest.WinForms.Tests/Forms/SavedNestsFormTests.cs b/OpenNest.WinForms.Tests/Forms/SavedNestsFormTests.cs
index 2f89efa..64295ca 100644
--- a/OpenNest.WinForms.Tests/Forms/SavedNestsFormTests.cs
+++ b/OpenNest.WinForms.Tests/Forms/SavedNestsFormTests.cs
@@ -21,8 +21,8 @@ public class SavedNestsFormTests
var first = Record("First job");
var second = Record("Second job");
var repository = new FakeRepository(first, second);
- repository.Files[first.Id] = NestFile(plateQuantity: 2, partsOnPlate: 3);
- repository.Files[second.Id] = NestFile(plateQuantity: 1, partsOnPlate: 1);
+ repository.Files[first.Id] = NestFile((Quantity: 2, Parts: 3));
+ repository.Files[second.Id] = NestFile((Quantity: 1, Parts: 1));
using var form = new SavedNestsForm(repository, "http://nest-server:5000");
form.Show();
@@ -56,6 +56,46 @@ public class SavedNestsFormTests
"The saved-nest dialog test did not complete.");
}
+ [Fact]
+ public void PlatePreview_StepsThroughThePlates_AndFollowsThePlatesTable()
+ {
+ StaTestThread.Run(
+ () =>
+ {
+ var job = Record("Two plates");
+ var repository = new FakeRepository(job);
+ repository.Files[job.Id] = NestFile((Quantity: 1, Parts: 3), (Quantity: 1, Parts: 1));
+
+ using var form = new SavedNestsForm(repository);
+ form.Show();
+ var plates = Field(form, "platesGrid");
+ var preview = Field(form, "platePreview");
+ var label = Field(form, "plateLabel");
+ var previous = Field(form, "previousPlateButton");
+ var next = Field(form, "nextPlateButton");
+
+ PumpUntil(() => plates.Rows.Count == 2, "the nest's plates");
+ Assert.True(preview.Visible);
+ Assert.Equal("Plate 1 of 2", label.Text);
+ Assert.Equal(3, preview.Plate.Parts.Count);
+ Assert.False(previous.Enabled);
+ Assert.True(next.Enabled);
+
+ next.PerformClick();
+ Assert.Equal("Plate 2 of 2", label.Text);
+ Assert.Single(preview.Plate.Parts);
+ Assert.Equal(1, plates.CurrentRow!.Index);
+ Assert.True(previous.Enabled);
+ Assert.False(next.Enabled);
+
+ plates.CurrentCell = plates.Rows[0].Cells[0];
+ Assert.Equal("Plate 1 of 2", label.Text);
+ Assert.Equal(3, preview.Plate.Parts.Count);
+ },
+ TestTimeout,
+ "The saved-nest dialog test did not complete.");
+ }
+
[Fact]
public void MissingArchive_ShowsTheErrorInTheDetailsLine_AndEscapeCancels()
{
@@ -69,6 +109,8 @@ public class SavedNestsFormTests
PumpUntil(() => status.Text.Contains("no longer exists"), "the missing-nest message");
Assert.Equal(0, Field(form, "platesGrid").Rows.Count);
+ Assert.False(Field(form, "platePreview").Visible);
+ Assert.Equal("", Field(form, "plateLabel").Text);
Assert.True(ProcessKey(form, Keys.Escape));
Assert.Equal(DialogResult.Cancel, form.DialogResult);
@@ -81,7 +123,7 @@ public class SavedNestsFormTests
private static NestRecord Record(string name) =>
new() { Id = Guid.NewGuid(), Name = name, SavedAt = DateTime.Now };
- private static byte[] NestFile(int plateQuantity, int partsOnPlate)
+ private static byte[] NestFile(params (int Quantity, int Parts)[] plates)
{
var nest = new Nest("Job") { Units = Units.Inches };
var program = new OpenNest.CNC.Program();
@@ -94,11 +136,14 @@ public class SavedNestsFormTests
drawing.Quantity.Required = 10;
nest.Drawings.Add(drawing);
- var plate = nest.CreatePlate();
- plate.Size = new OpenNest.Geometry.Size(48, 96);
- plate.Quantity = plateQuantity;
- for (var i = 0; i < partsOnPlate; i++)
- plate.Parts.Add(new Part(drawing, new Vector(i * 12, 0)));
+ foreach (var (quantity, parts) in plates)
+ {
+ var plate = nest.CreatePlate();
+ plate.Size = new OpenNest.Geometry.Size(48, 96);
+ plate.Quantity = quantity;
+ for (var i = 0; i < parts; i++)
+ plate.Parts.Add(new Part(drawing, new Vector(i * 12, 0)));
+ }
using var stream = new MemoryStream();
new NestWriter(nest).Write(stream);
diff --git a/OpenNest/Forms/SavedNestsForm.cs b/OpenNest/Forms/SavedNestsForm.cs
index 4addaed..2a8c5c3 100644
--- a/OpenNest/Forms/SavedNestsForm.cs
+++ b/OpenNest/Forms/SavedNestsForm.cs
@@ -1,10 +1,12 @@
using System;
+using System.Collections.Generic;
using System.Drawing;
using System.Globalization;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
+using OpenNest.Controls;
using OpenNest.Data;
using OpenNest.IO;
using Timer = System.Windows.Forms.Timer;
@@ -15,8 +17,8 @@ namespace OpenNest.Forms;
/// File > Open (Database mode). The upper list browses saved nests on the shared nest
/// server one bounded page at a time; filtering, sorting and paging run on the server
/// through . The lower tabs show the plates and drawings of
-/// the highlighted nest, read from its archive through .
-/// This form only renders their state.
+/// the highlighted nest, read from its archive through ,
+/// beside a preview of one of its plates. This form only renders their state.
///
public sealed class SavedNestsForm : Form
{
@@ -38,6 +40,11 @@ public sealed class SavedNestsForm : Form
private readonly DataGridView platesGrid;
private readonly DataGridView drawingsGrid;
private readonly TabControl detailsTabs;
+ private readonly SplitContainer detailsSplit;
+ private readonly PlateView platePreview;
+ private readonly Button previousPlateButton;
+ private readonly Button nextPlateButton;
+ private readonly Label plateLabel;
private readonly Label detailsStatus;
private readonly TextBox searchBox;
private readonly Timer searchTimer;
@@ -48,6 +55,8 @@ public sealed class SavedNestsForm : Form
private readonly ToolStripMenuItem openMenuItem;
private readonly ToolStripMenuItem deleteMenuItem;
private bool deleting;
+ private IReadOnlyList previewPlates = Array.Empty();
+ private int previewIndex = -1;
/// Set to the chosen record's id when the dialog closes with OK.
public Guid SelectedId { get; private set; }
@@ -128,6 +137,11 @@ public sealed class SavedNestsForm : Form
AddColumn(platesGrid, "Parts", "Parts", 70, typeof(int), alignRight: true);
AddColumn(platesGrid, "Drawings", "Drawings", 80, typeof(int), alignRight: true);
AddColumn(platesGrid, "Utilization", "Utilization", 90, typeof(double), alignRight: true, format: "P1");
+ platesGrid.SelectionChanged += (_, _) =>
+ {
+ if (platesGrid.CurrentRow is { } row)
+ ShowPlate(row.Index);
+ };
drawingsGrid = CreateGrid();
drawingsGrid.RowHeadersVisible = false;
@@ -142,6 +156,46 @@ public sealed class SavedNestsForm : Form
detailsTabs.TabPages.Add(CreateTab("Plates", platesGrid));
detailsTabs.TabPages.Add(CreateTab("Drawings", drawingsGrid));
+ // Preview of one plate beside the tables; the buttons below it step through the plates.
+ platePreview = new PlateView
+ {
+ Dock = DockStyle.Fill,
+ AllowSelect = false,
+ AllowDrop = false,
+ DrawOrigin = false,
+ Cursor = Cursors.Default,
+ TabStop = false,
+ Visible = false,
+ };
+ platePreview.SizeChanged += (_, _) =>
+ {
+ if (platePreview.Visible)
+ platePreview.ZoomToFit();
+ };
+ previousPlateButton = new Button { Text = "\u25C0", Width = 32, Enabled = false, AccessibleName = "Previous plate" };
+ previousPlateButton.Click += (_, _) => ShowPlate(previewIndex - 1);
+ nextPlateButton = new Button { Text = "\u25B6", Width = 32, Enabled = false, AccessibleName = "Next plate" };
+ nextPlateButton.Click += (_, _) => ShowPlate(previewIndex + 1);
+ plateLabel = new Label { Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleCenter, AutoEllipsis = true };
+
+ var plateBar = new TableLayoutPanel { Dock = DockStyle.Bottom, AutoSize = true, ColumnCount = 3, RowCount = 1 };
+ plateBar.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
+ plateBar.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
+ plateBar.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
+ plateBar.Controls.Add(previousPlateButton, 0, 0);
+ plateBar.Controls.Add(plateLabel, 1, 0);
+ plateBar.Controls.Add(nextPlateButton, 2, 0);
+
+ detailsSplit = new SplitContainer
+ {
+ Dock = DockStyle.Fill,
+ Orientation = Orientation.Vertical,
+ SplitterWidth = 6,
+ };
+ detailsSplit.Panel1.Controls.Add(detailsTabs);
+ detailsSplit.Panel2.Controls.Add(platePreview);
+ detailsSplit.Panel2.Controls.Add(plateBar);
+
detailsStatus = new Label
{
Dock = DockStyle.Fill,
@@ -161,7 +215,7 @@ public sealed class SavedNestsForm : Form
};
split.Panel1.Controls.Add(nestGrid);
split.Panel1.Controls.Add(SectionLabel("Nests", DockStyle.Top));
- split.Panel2.Controls.Add(detailsTabs);
+ split.Panel2.Controls.Add(detailsSplit);
split.Panel2.Controls.Add(detailsHeader);
// Find bar and paging.
@@ -226,6 +280,9 @@ public sealed class SavedNestsForm : Form
split.Panel1MinSize = 120;
split.Panel2MinSize = 120;
split.SplitterDistance = System.Math.Max(split.Panel1MinSize, split.Height * 3 / 5);
+ detailsSplit.Panel1MinSize = 240;
+ detailsSplit.Panel2MinSize = 160;
+ detailsSplit.SplitterDistance = System.Math.Max(detailsSplit.Panel1MinSize, detailsSplit.Width * 3 / 5);
};
Shown += async (_, _) =>
{
@@ -518,6 +575,13 @@ public sealed class SavedNestsForm : Form
private void ShowDetailsMessage(string message)
{
+ previewPlates = Array.Empty();
+ previewIndex = -1;
+ platePreview.Visible = false;
+ plateLabel.Text = "";
+ previousPlateButton.Enabled = false;
+ nextPlateButton.Enabled = false;
+
platesGrid.Rows.Clear();
drawingsGrid.Rows.Clear();
detailsStatus.Text = message;
@@ -531,6 +595,7 @@ public sealed class SavedNestsForm : Form
ShowDetailsMessage(
$"{nestDetails.Plates.Count:N0} plate(s), {nestDetails.Drawings.Count:N0} drawing(s)");
+ previewPlates = nestDetails.PlateLayouts;
foreach (var plate in nestDetails.Plates)
{
platesGrid.Rows.Add(
@@ -552,6 +617,32 @@ public sealed class SavedNestsForm : Form
drawing.Remaining,
drawing.Area);
}
+
+ ShowPlate(0);
+ }
+
+ ///
+ /// Shows one plate of the highlighted nest in the preview and highlights its row in the
+ /// Plates table. Plate buttons and row selection both come through here.
+ ///
+ private void ShowPlate(int index)
+ {
+ if (index < 0 || index >= previewPlates.Count)
+ return;
+
+ if (index != previewIndex)
+ {
+ previewIndex = index;
+ platePreview.Plate = previewPlates[index];
+ platePreview.Visible = true;
+ platePreview.ZoomToFit();
+ }
+
+ plateLabel.Text = $"Plate {index + 1:N0} of {previewPlates.Count:N0}";
+ previousPlateButton.Enabled = index > 0;
+ nextPlateButton.Enabled = index < previewPlates.Count - 1;
+ if (index < platesGrid.Rows.Count && platesGrid.CurrentRow?.Index != index)
+ platesGrid.CurrentCell = platesGrid.Rows[index].Cells[0];
}
private void OpenSelected()
diff --git a/docs/nest-storage.md b/docs/nest-storage.md
index 6d897c6..eb95485 100644
--- a/docs/nest-storage.md
+++ b/docs/nest-storage.md
@@ -13,15 +13,18 @@ The desktop app can save nests two ways:
Below the list, the **Details** tabs show the highlighted nest's plates
(duplicates, size, parts, drawings, utilization) and drawings (required, nested,
remaining, area); they are read from that nest's archive, which is downloaded
- shortly after the highlight stops moving. Enter or a double-click opens the
- highlighted nest and Esc closes. Save creates a record on first save and updates
- that same record afterward. Save As creates a new record (a copy). A failed save
- keeps the previous record association; switching server URLs creates a record on
- the new server rather than updating an id from the previous one. Delete on the
- Open dialog's right-click menu permanently removes the highlighted server record
- after confirmation. A separate **File > Export .nest...** command is always
- available (in both modes) for producing a local file to share or back up. Export
- does not change the document's file save path or database record association.
+ shortly after the highlight stops moving. Beside the tabs, a preview draws one
+ plate of the highlighted nest; the arrow buttons below it step through the
+ plates, and selecting a row on the Plates tab shows that plate. Enter or a
+ double-click opens the highlighted nest and Esc closes. Save creates a record on
+ first save and updates that same record afterward. Save As creates a new record
+ (a copy). A failed save keeps the previous record association; switching server
+ URLs creates a record on the new server rather than updating an id from the
+ previous one. Delete on the Open dialog's right-click menu permanently removes
+ the highlighted server record after confirmation. A separate **File > Export
+ .nest...** command is always available (in both modes) for producing a local
+ file to share or back up. Export does not change the document's file save path
+ or database record association.
The mode and server address are stored per-PC at `%APPDATA%\OpenNest\storage.json`
(`OpenNest.Data.NestStorageSettings`), defaulting to File mode so existing