feat(ui): preview the highlighted nest's plates in the Open dialog

The Details area of the Database-mode Open dialog now has a plate
preview beside the Plates/Drawings tabs, with a draggable divider. It
draws one plate of the highlighted nest; the arrow buttons below it step
through the plates ("Plate 2 of 5"), and choosing a row on the Plates tab
shows that plate, so the table and the preview stay on the same plate.
The preview is read-only (no selection or drop) and refits when resized.

NestDetails keeps the downloaded nest's plates (PlateLayouts) beside its
rows, so the preview draws the copy already read for the tables with no
second download.

Tests: NestDetails keeps every plate in nest order (fails with the
assignment removed). The Windows form test steps forward with the
button, checks the row follows, and selects a row back.
This commit is contained in:
aj committed 2026-10-05 11:44:22 -04:00
1 parent 9920aba6eb
commit da0c54f280
5 files changed
+189 -21

No files matched your search

+13 -1
View File
@@ -17,6 +17,12 @@ public sealed class NestDetails
/// <summary>One row per drawing (cutoffs excluded), in nest order.</summary>
public IReadOnlyList<NestDrawingDetail> Drawings { get; init; } = Array.Empty<NestDrawingDetail>();
/// <summary>
/// The plates themselves, in the same order as <see cref="Plates"/>, for the plate
/// preview. They belong to the downloaded copy of the nest, never to an open document.
/// </summary>
public IReadOnlyList<Plate> PlateLayouts { get; init; } = Array.Empty<Plate>();
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(),
};
}
}
+17
View File
@@ -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()
{
@@ -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<DataGridView>(form, "platesGrid");
var preview = Field<OpenNest.Controls.PlateView>(form, "platePreview");
var label = Field<Label>(form, "plateLabel");
var previous = Field<Button>(form, "previousPlateButton");
var next = Field<Button>(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<DataGridView>(form, "platesGrid").Rows.Count);
Assert.False(Field<OpenNest.Controls.PlateView>(form, "platePreview").Visible);
Assert.Equal("", Field<Label>(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);
+94 -3
View File
@@ -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 &gt; 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 <see cref="NestBrowseSession"/>. The lower tabs show the plates and drawings of
/// the highlighted nest, read from its archive through <see cref="NestDetailsSession"/>.
/// This form only renders their state.
/// the highlighted nest, read from its archive through <see cref="NestDetailsSession"/>,
/// beside a preview of one of its plates. This form only renders their state.
/// </summary>
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<Plate> previewPlates = Array.Empty<Plate>();
private int previewIndex = -1;
/// <summary>Set to the chosen record's id when the dialog closes with OK.</summary>
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<Plate>();
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);
}
/// <summary>
/// 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.
/// </summary>
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()
+12 -9
View File
@@ -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