From b341848e07778c88bf144ea46546e8b3b46ef7fe Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sat, 24 Oct 2026 21:12:58 -0400 Subject: [PATCH] feat(desktop): preview a drawing set's drawings together at one scale Preview on the Sets tab (button and right-click menu) downloads each member's current revision and packs them into one view, labeled with name and quantity, so part sizes compare at a glance. PlateView gains DrawPartNumbers (default on) so the preview can hide sequence numbers. Co-Authored-By: Claude Opus 5.5 --- .../Controls/DrawingSetPreviewTests.cs | 144 +++++++++++++++ .../Controls/DrawingSetBrowser.Designer.cs | 35 +++- OpenNest/Controls/DrawingSetBrowser.cs | 69 ++++++- OpenNest/Controls/DrawingSetPreviewControl.cs | 169 ++++++++++++++++++ OpenNest/Controls/DrawingSetPreviewLayout.cs | 146 +++++++++++++++ OpenNest/Controls/PlateRenderer.cs | 2 +- OpenNest/Controls/PlateView.cs | 3 + OpenNest/Forms/DrawingSetPreviewForm.cs | 160 +++++++++++++++++ OpenNest/LayoutPart.cs | 2 + 9 files changed, 719 insertions(+), 11 deletions(-) create mode 100644 OpenNest.WinForms.Tests/Controls/DrawingSetPreviewTests.cs create mode 100644 OpenNest/Controls/DrawingSetPreviewControl.cs create mode 100644 OpenNest/Controls/DrawingSetPreviewLayout.cs create mode 100644 OpenNest/Forms/DrawingSetPreviewForm.cs 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 {