diff --git a/OpenNest.WinForms.Tests/Controls/DrawingSetPreviewTests.cs b/OpenNest.WinForms.Tests/Controls/DrawingSetPreviewTests.cs
new file mode 100644
index 00000000..35a41282
--- /dev/null
+++ b/OpenNest.WinForms.Tests/Controls/DrawingSetPreviewTests.cs
@@ -0,0 +1,144 @@
+using OpenNest.Controls;
+using OpenNest.Data;
+using OpenNest.Forms;
+using OpenNest.Geometry;
+
+namespace OpenNest.WinForms.Tests.Controls;
+
+///
+/// The set preview packs every drawing into one view at one scale, without overlaps, each in
+/// its own orientation, and stays inside the view.
+///
+public class DrawingSetPreviewTests
+{
+ private static Drawing Rectangle(string name, double width, double height, double x = 0, double y = 0)
+ {
+ var program = new CNC.Program();
+ program.Codes.Add(new CNC.RapidMove(new Vector(x, y)));
+ program.Codes.Add(new CNC.LinearMove(new Vector(x + width, y)));
+ program.Codes.Add(new CNC.LinearMove(new Vector(x + width, y + height)));
+ program.Codes.Add(new CNC.LinearMove(new Vector(x, y + height)));
+ program.Codes.Add(new CNC.LinearMove(new Vector(x, y)));
+ return new Drawing(name, program);
+ }
+
+ [Fact]
+ public void Arrange_PlacesEveryCellApartAndInsideTheLayout()
+ {
+ var sizes = new List<(double, double)> { (40, 10), (3, 3), (12, 30), (1, 0.5), (25, 25), (60, 2), (5, 8) };
+
+ var result = DrawingSetPreviewLayout.Arrange(sizes, 800, 600, 18, 90);
+
+ Assert.Equal(sizes.Count, result.Placements.Count);
+ Assert.True(result.Scale > 0);
+ Assert.True(result.Width * result.Scale <= 800 + 1e-6);
+ Assert.True(result.Height * result.Scale <= 600 + 1e-6);
+ // The strip is solved together with the scale, so it lands within a pixel of the request.
+ Assert.InRange(result.LabelHeight * result.Scale, 17, 19);
+
+ // Each cell covers its part's width (at least the minimum) and its label strip below.
+ var cells = result.Placements.Select(p =>
+ {
+ var (width, height) = sizes[p.Index];
+ Assert.InRange(p.PartX, p.CellX - 1e-9, p.CellX + p.CellWidth - width + 1e-9);
+ Assert.True(p.CellWidth * result.Scale >= 89);
+ Assert.Equal(p.LabelTop, p.PartY, 9);
+ return (Left: p.CellX, Right: p.CellX + p.CellWidth, Bottom: p.PartY - result.LabelHeight, Top: p.PartY + height);
+ }).ToList();
+
+ foreach (var cell in cells)
+ {
+ Assert.InRange(cell.Left, -1e-9, result.Width + 1e-9);
+ Assert.InRange(cell.Right, -1e-9, result.Width + 1e-9);
+ Assert.InRange(cell.Bottom, -result.Height - 1e-9, 1e-9);
+ Assert.InRange(cell.Top, -result.Height - 1e-9, 1e-9);
+ }
+
+ for (var i = 0; i < cells.Count; i++)
+ for (var j = i + 1; j < cells.Count; j++)
+ {
+ var a = cells[i];
+ var b = cells[j];
+ var apart = a.Right <= b.Left + 1e-9 || b.Right <= a.Left + 1e-9 || a.Top <= b.Bottom + 1e-9 || b.Top <= a.Bottom + 1e-9;
+ Assert.True(apart, $"cells {i} and {j} overlap");
+ }
+ }
+
+ [Fact]
+ public void Arrange_UsesTheViewShape()
+ {
+ var sizes = Enumerable.Repeat((10.0, 10.0), 12).ToList();
+
+ var wide = DrawingSetPreviewLayout.Arrange(sizes, 1200, 300, 0, 0);
+ var tall = DrawingSetPreviewLayout.Arrange(sizes, 300, 1200, 0, 0);
+
+ Assert.True(wide.Width > wide.Height);
+ Assert.True(tall.Height > tall.Width);
+ }
+
+ [Fact]
+ public void Control_ShowsEveryDrawingAtItsOwnSize() => StaTestThread.Run(() =>
+ {
+ using var view = new DrawingSetPreviewControl { Size = new System.Drawing.Size(900, 700) };
+ var drawings = new[]
+ {
+ (Rectangle("large", 48, 20, x: 100, y: -50), "large ×1"),
+ (Rectangle("small", 2, 1), "small ×10"),
+ (new Drawing("empty"), "empty ×1"),
+ };
+
+ view.ShowDrawings(drawings);
+
+ Assert.Equal(2, view.Parts.Count);
+ Assert.Equal(2, view.Plate.Parts.Count);
+ var large = view.Parts[0].BoundingBox;
+ var small = view.Parts[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);
+
+ // 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);
+ }, TimeSpan.FromMinutes(1), "The STA test did not complete.");
+
+ [Fact]
+ public void Form_LoadsEachMemberAndCountsTheMissingOnes() => 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
+ {
+ 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, _) =>
+ {
+ 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();
+ var until = DateTime.UtcNow.AddSeconds(15);
+ while (!load.IsCompleted && 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.");
+}
diff --git a/OpenNest/Controls/DrawingSetBrowser.Designer.cs b/OpenNest/Controls/DrawingSetBrowser.Designer.cs
index 7bc68c94..55677f7c 100644
--- a/OpenNest/Controls/DrawingSetBrowser.Designer.cs
+++ b/OpenNest/Controls/DrawingSetBrowser.Designer.cs
@@ -42,6 +42,7 @@ 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();
@@ -49,6 +50,7 @@ namespace OpenNest.Controls
moreMenu = new System.Windows.Forms.ContextMenuStrip(components);
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();
@@ -220,7 +222,7 @@ namespace OpenNest.Controls
// bottomTable
//
bottomTable.AutoSize = true;
- bottomTable.ColumnCount = 7;
+ bottomTable.ColumnCount = 8;
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));
@@ -228,13 +230,15 @@ 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(moreButton, 5, 0);
- bottomTable.Controls.Add(releaseButton, 6, 0);
+ bottomTable.Controls.Add(previewButton, 5, 0);
+ bottomTable.Controls.Add(moreButton, 6, 0);
+ bottomTable.Controls.Add(releaseButton, 7, 0);
bottomTable.Dock = System.Windows.Forms.DockStyle.Fill;
bottomTable.Margin = new System.Windows.Forms.Padding(0, 6, 0, 0);
bottomTable.Name = "bottomTable";
@@ -287,12 +291,22 @@ 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 = 5;
+ moreButton.TabIndex = 6;
moreButton.Text = "M&ore ▾";
moreButton.UseVisualStyleBackColor = true;
moreButton.Click += MoreButton_Click;
@@ -304,7 +318,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 = 6;
+ releaseButton.TabIndex = 7;
releaseButton.Text = "&Make Nests...";
releaseButton.UseVisualStyleBackColor = true;
releaseButton.Visible = false;
@@ -326,10 +340,17 @@ namespace OpenNest.Controls
//
// moreMenu
//
- moreMenu.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { archiveMenuItem, historyMenuItem, releasesMenuItem, importSeparator, importBomMenuItem });
+ moreMenu.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { previewMenuItem, archiveMenuItem, historyMenuItem, releasesMenuItem, importSeparator, importBomMenuItem });
moreMenu.Name = "moreMenu";
moreMenu.Opening += MoreMenu_Opening;
//
+ // previewMenuItem
+ //
+ previewMenuItem.Enabled = false;
+ previewMenuItem.Name = "previewMenuItem";
+ previewMenuItem.Text = "&Preview...";
+ previewMenuItem.Click += PreviewButton_Click;
+ //
// archiveMenuItem
//
archiveMenuItem.Enabled = false;
@@ -405,6 +426,7 @@ 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;
@@ -412,6 +434,7 @@ namespace OpenNest.Controls
private System.Windows.Forms.ContextMenuStrip moreMenu;
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 5c36a270..4e52677a 100644
--- a/OpenNest/Controls/DrawingSetBrowser.cs
+++ b/OpenNest/Controls/DrawingSetBrowser.cs
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Data;
using OpenNest.Forms;
+using OpenNest.IO;
namespace OpenNest.Controls
{
@@ -15,9 +16,10 @@ namespace OpenNest.Controls
/// Browses the server's drawing sets one bounded page at a time through
/// , and creates, edits, archives or restores them and
/// shows their history. hosts it on its Sets tab to manage and
- /// release sets, and shows a set's Releases on its Releases tab. New Set, Edit and Make Nests
- /// are buttons; Archive/Restore, History and Releases, then Import BOM, are in the More menu,
- /// which is also the list's right-click menu. In pick mode
+ /// release sets, and shows a set's Releases on its Releases tab. New Set, Edit, Preview and
+ /// Make Nests are buttons; 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
/// () only active sets are listed, and choosing a set or
/// New Set raises or instead.
///
@@ -52,6 +54,9 @@ 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; }
+
/// Shows the release dialog for a set read with its members; replaced by tests.
internal Action ShowRelease { get; set; }
@@ -121,6 +126,15 @@ namespace OpenNest.Controls
return form.ShowDialog(FindForm()) == DialogResult.OK ? form.Edit : null;
};
setGrid.ContextMenuStrip = pick ? null : moreMenu;
+ ShowPreview = set =>
+ {
+ 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());
+ };
ShowRelease = set =>
{
using var form = new ReleaseSetForm(sets, library, nests, this.serverUrl, set, openNest) { IsNestOpen = isNestOpen };
@@ -132,6 +146,7 @@ namespace OpenNest.Controls
newButton.Text = "New Set";
showArchivedCheckBox.Visible = false;
editButton.Visible = false;
+ previewButton.Visible = false;
moreButton.Visible = false;
}
@@ -246,6 +261,8 @@ 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)
@@ -332,6 +349,49 @@ namespace OpenNest.Controls
ShowRelease(current);
}
+ ///
+ /// 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 })
@@ -494,12 +554,13 @@ namespace OpenNest.Controls
var ready = session != null && !busy;
newButton.Enabled = ready;
editButton.Enabled = ready && selected is { Archived: false };
+ 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 = archiveMenuItem.Enabled || historyMenuItem.Enabled || releasesMenuItem.Enabled
+ moreButton.Enabled = previewMenuItem.Enabled || archiveMenuItem.Enabled || historyMenuItem.Enabled || releasesMenuItem.Enabled
|| importBomMenuItem.Enabled;
releaseButton.Enabled = ready && nests != null && selected is { Archived: false, MemberCount: > 0 };
}
diff --git a/OpenNest/Controls/DrawingSetPreviewControl.cs b/OpenNest/Controls/DrawingSetPreviewControl.cs
new file mode 100644
index 00000000..19260133
--- /dev/null
+++ b/OpenNest/Controls/DrawingSetPreviewControl.cs
@@ -0,0 +1,169 @@
+using System.Collections.Generic;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+using System.Linq;
+using System.Windows.Forms;
+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.
+ ///
+ public class DrawingSetPreviewControl : PlateView
+ {
+ private const int MinCellPixels = 90;
+
+ private readonly List<(Part Part, string Label)> items = new();
+ private DrawingSetPreviewLayout.Result layout;
+ private string[] infoLines;
+
+ public DrawingSetPreviewControl()
+ {
+ DrawOrigin = false;
+ DrawBounds = false;
+ DrawPartNumbers = false;
+ AllowPan = true;
+ AllowSelect = false;
+ AllowZoom = true;
+ AllowDrop = false;
+ BackColor = Color.White;
+ // A large set in millimeters fits at well under the default floor of 0.3 px per unit.
+ ViewScaleMin = 1e-5f;
+ }
+
+ /// The placed parts, in the order they were given.
+ internal IReadOnlyList Parts => items.Select(i => i.Part).ToList();
+
+ internal DrawingSetPreviewLayout.Result Layout => layout;
+
+ public void SetInfo(params string[] lines)
+ {
+ infoLines = lines;
+ Invalidate();
+ }
+
+ /// Replaces the shown drawings; each is labeled with its text.
+ public void ShowDrawings(IEnumerable<(Drawing Drawing, string Label)> drawings)
+ {
+ Plate.Parts.Clear();
+ Plate.Size = new Geometry.Size(0, 0);
+ items.Clear();
+ layout = null;
+
+ foreach (var (drawing, label) in drawings)
+ {
+ if (!CanShow(drawing))
+ continue;
+ var part = new Part(drawing, Vector.Zero);
+ Plate.Parts.Add(part);
+ items.Add((part, label ?? ""));
+ }
+
+ 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)
+ {
+ if (redraw)
+ Invalidate();
+ return;
+ }
+
+ ZoomToArea(0, -layout.Height, layout.Width, layout.Height, redraw);
+ }
+
+ protected override void OnResize(System.EventArgs e)
+ {
+ base.OnResize(e);
+ if (items.Count > 0)
+ Arrange();
+ }
+
+ private void Arrange()
+ {
+ if (items.Count == 0 || Width <= BorderWidth || Height <= BorderWidth)
+ {
+ 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);
+
+ foreach (var placement in layout.Placements)
+ {
+ var part = items[placement.Index].Part;
+ var box = part.BoundingBox;
+ part.Offset(placement.PartX - box.X, placement.PartY - box.Y);
+ }
+
+ // Parts were moved directly, so the cached display geometry must be rebuilt.
+ Plate.Parts.Clear();
+ foreach (var item in items)
+ Plate.Parts.Add(item.Part);
+ ZoomToFit();
+ }
+
+ protected override void OnPaint(PaintEventArgs e)
+ {
+ e.Graphics.SmoothingMode = SmoothingMode.HighQuality;
+
+ e.Graphics.TranslateTransform(origin.X, origin.Y);
+ Renderer.DrawPlate(e.Graphics);
+ Renderer.DrawParts(e.Graphics);
+ PaintLabels(e.Graphics);
+ e.Graphics.ResetTransform();
+
+ PaintInfo(e.Graphics);
+ }
+
+ private void PaintLabels(Graphics g)
+ {
+ if (layout == null)
+ return;
+
+ using var format = new StringFormat
+ {
+ Alignment = StringAlignment.Center,
+ LineAlignment = StringAlignment.Near,
+ Trimming = StringTrimming.EllipsisCharacter,
+ FormatFlags = StringFormatFlags.NoWrap,
+ };
+
+ foreach (var placement in layout.Placements)
+ {
+ var corner = PointWorldToGraph(placement.CellX, placement.LabelTop);
+ var rect = new RectangleF(corner.X, corner.Y + 1,
+ LengthWorldToGui(placement.CellWidth), System.Math.Max(LengthWorldToGui(layout.LabelHeight), Font.Height));
+ g.DrawString(items[placement.Index].Label, Font, Brushes.Black, rect, format);
+ }
+ }
+
+ private void PaintInfo(Graphics g)
+ {
+ if (infoLines == null)
+ return;
+
+ var lineHeight = Font.GetHeight(g) + 1;
+ var y = 4f;
+
+ foreach (var line in infoLines)
+ {
+ if (string.IsNullOrEmpty(line))
+ continue;
+ g.DrawString(line, Font, Brushes.Black, 4, y);
+ y += lineHeight;
+ }
+ }
+ }
+}
diff --git a/OpenNest/Controls/DrawingSetPreviewLayout.cs b/OpenNest/Controls/DrawingSetPreviewLayout.cs
new file mode 100644
index 00000000..b7b70ee3
--- /dev/null
+++ b/OpenNest/Controls/DrawingSetPreviewLayout.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace OpenNest.Controls
+{
+ ///
+ /// Packs a set's drawings into rows for , every one at
+ /// the same scale and in its own orientation. Rows are filled tallest first (next-fit
+ /// decreasing height) and each row's parts share a baseline with a label strip beneath it.
+ /// The row width is chosen to give the largest fit scale in the view, and the label strip
+ /// and minimum cell width are fixed in pixels, so they are solved together with that scale.
+ ///
+ internal static class DrawingSetPreviewLayout
+ {
+ private const int RowWidthCandidates = 48;
+ private const int ScaleIterations = 4;
+
+ /// Where one drawing lands: its bounding box's lower-left corner and its label cell, in world units (Y up).
+ internal readonly record struct Placement(int Index, double PartX, double PartY, double CellX, double CellWidth, double LabelTop);
+
+ internal sealed class Result
+ {
+ public IReadOnlyList Placements { get; init; } = Array.Empty();
+
+ /// The whole layout spans X 0..Width and Y -Height..0.
+ public double Width { get; init; }
+
+ public double Height { get; init; }
+
+ /// Pixels per world unit when the layout is fitted to the view.
+ public double Scale { get; init; }
+
+ /// Height of the label strip under each row, in world units.
+ public double LabelHeight { get; init; }
+ }
+
+ /// Each drawing's bounding-box width (X) and height (Y).
+ /// Usable view width in pixels (the fit border already removed).
+ /// Usable view height in pixels.
+ /// Height of a label line in pixels.
+ /// Narrowest cell in pixels, so a small part's label stays readable.
+ public static Result Arrange(IReadOnlyList<(double Width, double Height)> sizes, double viewWidth, double viewHeight,
+ double labelPixels, double minCellPixels)
+ {
+ if (sizes == null)
+ throw new ArgumentNullException(nameof(sizes));
+ if (sizes.Count == 0)
+ return new Result();
+
+ viewWidth = System.Math.Max(viewWidth, 1);
+ viewHeight = System.Math.Max(viewHeight, 1);
+ var clean = sizes.Select(s => (Width: Positive(s.Width), Height: Positive(s.Height))).ToArray();
+ var largest = clean.Max(s => System.Math.Max(s.Width, s.Height));
+ var spacing = largest * 0.04;
+ var order = Enumerable.Range(0, clean.Length)
+ .OrderByDescending(i => clean[i].Height)
+ .ThenBy(i => i)
+ .ToArray();
+
+ // Any row narrower than the widest part (plus its spacing) cannot hold it; wider
+ // than every part side by side is one row. Candidates are spread geometrically between.
+ var narrowest = clean.Max(s => s.Width);
+ var widest = clean.Sum(s => s.Width) + spacing * clean.Length;
+ Result best = null;
+ for (var c = 0; c <= RowWidthCandidates; c++)
+ {
+ var rowWidth = narrowest * System.Math.Pow(widest / narrowest, (double)c / RowWidthCandidates);
+ var scale = System.Math.Min(viewWidth / widest, viewHeight / clean.Sum(s => s.Height));
+ Result result = null;
+ for (var i = 0; i < ScaleIterations; i++)
+ {
+ result = Pack(clean, order, rowWidth, spacing, labelPixels / scale, minCellPixels / scale);
+ scale = System.Math.Min(viewWidth / result.Width, viewHeight / result.Height);
+ }
+
+ // The label strip was sized for the previous scale; repack once at the final one
+ // so the reported scale and the strip agree.
+ result = Pack(clean, order, rowWidth, spacing, labelPixels / scale, minCellPixels / scale);
+ result = new Result
+ {
+ Placements = result.Placements,
+ Width = result.Width,
+ Height = result.Height,
+ LabelHeight = result.LabelHeight,
+ Scale = System.Math.Min(viewWidth / result.Width, viewHeight / result.Height),
+ };
+ if (best == null || result.Scale > best.Scale)
+ best = result;
+ }
+
+ return best;
+ }
+
+ private static Result Pack((double Width, double Height)[] sizes, int[] order, double rowWidth, double spacing,
+ double labelHeight, double minCell)
+ {
+ var placements = new Placement[sizes.Length];
+ var rowTop = 0.0;
+ var width = 0.0;
+ var start = 0;
+ while (start < order.Length)
+ {
+ // Next-fit: take parts while they fit the row; a row always takes at least one.
+ var x = 0.0;
+ var end = start;
+ while (end < order.Length)
+ {
+ var cell = System.Math.Max(sizes[order[end]].Width, minCell);
+ if (end > start && x + cell > rowWidth)
+ break;
+ x += cell + spacing;
+ end++;
+ }
+
+ // Order is tallest first, so the row's first part sets its height.
+ var rowHeight = sizes[order[start]].Height;
+ var baseline = -(rowTop + rowHeight);
+ x = 0.0;
+ for (var k = start; k < end; k++)
+ {
+ var index = order[k];
+ var size = sizes[index];
+ var cell = System.Math.Max(size.Width, minCell);
+ placements[index] = new Placement(index, x + (cell - size.Width) / 2, baseline, x, cell, baseline);
+ x += cell + spacing;
+ }
+
+ width = System.Math.Max(width, x - spacing);
+ rowTop += rowHeight + labelHeight + spacing;
+ start = end;
+ }
+
+ return new Result
+ {
+ Placements = placements,
+ Width = width,
+ Height = rowTop - spacing,
+ LabelHeight = labelHeight,
+ };
+ }
+
+ private static double Positive(double value) =>
+ double.IsFinite(value) && value > 0 ? value : 1e-6;
+ }
+}
diff --git a/OpenNest/Controls/PlateRenderer.cs b/OpenNest/Controls/PlateRenderer.cs
index bd26100c..50659701 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, number.ToString(), muted);
+ part.Draw(g, view.DrawPartNumbers ? number.ToString() : null, 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 1c66e844..bbafa244 100644
--- a/OpenNest/Controls/PlateView.cs
+++ b/OpenNest/Controls/PlateView.cs
@@ -191,6 +191,9 @@ 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
new file mode 100644
index 00000000..3d0351c2
--- /dev/null
+++ b/OpenNest/Forms/DrawingSetPreviewForm.cs
@@ -0,0 +1,160 @@
+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 9df6dd7f..66dd0a42 100644
--- a/OpenNest/LayoutPart.cs
+++ b/OpenNest/LayoutPart.cs
@@ -118,6 +118,8 @@ namespace OpenNest
internal void Draw(Graphics g, string id, bool muted)
{
Draw(g, muted);
+ if (id == null)
+ return;
using var sf = new StringFormat
{