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 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-24 21:12:58 -04:00
1 parent 33f60d3dcb
commit b341848e07
9 files changed
+719 -11

No files matched your search

@@ -0,0 +1,144 @@
using OpenNest.Controls;
using OpenNest.Data;
using OpenNest.Forms;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests.Controls;
/// <summary>
/// The set preview packs every drawing into one view at one scale, without overlaps, each in
/// its own orientation, and stays inside the view.
/// </summary>
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.");
}
+29 -6
View File
@@ -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;
+65 -4
View File
@@ -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
/// <see cref="DrawingSetBrowseSession"/>, and creates, edits, archives or restores them and
/// shows their history. <see cref="DrawingSetsForm"/> 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 (<see cref="DrawingSetPreviewForm"/>). In pick mode
/// (<see cref="ChooseDrawingSetForm"/>) only active sets are listed, and choosing a set or
/// New Set raises <see cref="SetActivated"/> or <see cref="NewSetChosen"/> instead.
/// </summary>
@@ -52,6 +54,9 @@ namespace OpenNest.Controls
/// </summary>
internal Func<DrawingSetEditSession> ImportBom { get; set; }
/// <summary>Shows the same-scale preview of a set read with its members; replaced by tests.</summary>
internal Action<DrawingSetRecord> ShowPreview { get; set; }
/// <summary>Shows the release dialog for a set read with its members; replaced by tests.</summary>
internal Action<DrawingSetRecord> 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);
}
/// <summary>
/// Reads the selected set (archived or not) with its members' current drawings and
/// shows them all in one window at the same scale.
/// </summary>
internal async Task PreviewSelectedAsync()
{
var listed = SelectedSet;
if (session == null || listed == null || busy || pick)
return;
DrawingSetRecord current;
busy = true;
UpdateCommands();
statusLabel.Text = $"Reading set '{listed.Name}'...";
try
{
current = await sets.GetAsync(listed.Id);
}
catch (Exception ex) when (ex is IOException || ex is HttpRequestException || ex is TaskCanceledException)
{
Fail($"Could not read set '{listed.Name}': {ex.Message}");
return;
}
finally
{
busy = false;
if (!IsDisposed)
UpdateCommands();
}
if (IsDisposed)
return;
statusLabel.Text = "";
if (current == null)
{
ShowMessage($"Set '{listed.Name}' no longer exists on the server.", MessageBoxButtons.OK, MessageBoxIcon.Warning);
await RunAsync(session.RefreshAsync);
return;
}
ShowPreview(current);
}
private void ActivateSelected()
{
if (SelectedSet is { Archived: false })
@@ -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 };
}
@@ -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
{
/// <summary>
/// Shows every drawing of a set side by side at one scale, packed by
/// <see cref="DrawingSetPreviewLayout"/> with a label under each. The layout is repacked
/// to fit whenever the control is resized; the wheel zooms, the middle button pans and a
/// middle double-click fits the set again.
/// </summary>
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;
}
/// <summary>The placed parts, in the order they were given.</summary>
internal IReadOnlyList<Part> Parts => items.Select(i => i.Part).ToList();
internal DrawingSetPreviewLayout.Result Layout => layout;
public void SetInfo(params string[] lines)
{
infoLines = lines;
Invalidate();
}
/// <summary>Replaces the shown drawings; each is labeled with its text.</summary>
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();
}
/// <summary>False for a drawing with no geometry to show; <see cref="ShowDrawings"/> skips it.</summary>
public static bool CanShow(Drawing drawing) => drawing?.Program?.Codes.Count > 0;
public override void ZoomToFit(bool redraw = true)
{
if (layout == null)
{
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;
}
}
}
}
@@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest.Controls
{
/// <summary>
/// Packs a set's drawings into rows for <see cref="DrawingSetPreviewControl"/>, 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.
/// </summary>
internal static class DrawingSetPreviewLayout
{
private const int RowWidthCandidates = 48;
private const int ScaleIterations = 4;
/// <summary>Where one drawing lands: its bounding box's lower-left corner and its label cell, in world units (Y up).</summary>
internal readonly record struct Placement(int Index, double PartX, double PartY, double CellX, double CellWidth, double LabelTop);
internal sealed class Result
{
public IReadOnlyList<Placement> Placements { get; init; } = Array.Empty<Placement>();
/// <summary>The whole layout spans X 0..Width and Y -Height..0.</summary>
public double Width { get; init; }
public double Height { get; init; }
/// <summary>Pixels per world unit when the layout is fitted to the view.</summary>
public double Scale { get; init; }
/// <summary>Height of the label strip under each row, in world units.</summary>
public double LabelHeight { get; init; }
}
/// <param name="sizes">Each drawing's bounding-box width (X) and height (Y).</param>
/// <param name="viewWidth">Usable view width in pixels (the fit border already removed).</param>
/// <param name="viewHeight">Usable view height in pixels.</param>
/// <param name="labelPixels">Height of a label line in pixels.</param>
/// <param name="minCellPixels">Narrowest cell in pixels, so a small part's label stays readable.</param>
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;
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ namespace OpenNest.Controls
|| view.MutedParts?.Contains(part.BasePart) == true;
var number = view.PreviewSequence != null
&& view.PreviewSequence.TryGetValue(part.BasePart, out var preview) ? preview : i + 1;
part.Draw(g, 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);
+3
View File
@@ -191,6 +191,9 @@ namespace OpenNest.Controls
public bool DrawBounds { get; set; }
/// <summary>Numbers each part with its position in the plate's sequence.</summary>
public bool DrawPartNumbers { get; set; } = true;
public bool DrawOffset { get; set; }
public bool DrawCutDirection { get; set; }
+160
View File
@@ -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
{
/// <summary>
/// Shows every drawing of a set in one <see cref="DrawingSetPreviewControl"/>, all at the
/// same scale, labeled with its name and the set's quantity. Each member's current
/// revision is downloaded when the window is shown; members that are gone or fail to
/// load are counted in the header and left out. Closing cancels the downloads.
/// </summary>
public class DrawingSetPreviewForm : Form
{
private const int Concurrency = 4;
private readonly DrawingSetRecord set;
private readonly Func<DrawingLibraryRecord, CancellationToken, Task<Drawing>> load;
private readonly CancellationTokenSource cancel = new();
private readonly DrawingSetPreviewControl preview;
/// <param name="set">The set read with its members' current drawings.</param>
/// <param name="load">Reads the drawing a record's package holds, or null when the revision is gone.</param>
public DrawingSetPreviewForm(DrawingSetRecord set, Func<DrawingLibraryRecord, CancellationToken, Task<Drawing>> load)
{
this.set = set ?? throw new ArgumentNullException(nameof(set));
this.load = load ?? throw new ArgumentNullException(nameof(load));
preview = new DrawingSetPreviewControl { Dock = DockStyle.Fill };
var closeButton = new Button
{
Text = "Close",
DialogResult = DialogResult.Cancel,
AutoSize = true,
Anchor = AnchorStyles.Right,
};
var bottom = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
AutoSize = true,
FlowDirection = FlowDirection.RightToLeft,
Padding = new Padding(6),
};
bottom.Controls.Add(closeButton);
Controls.Add(preview);
Controls.Add(bottom);
CancelButton = closeButton;
Font = new Font("Segoe UI", 9.75F);
Text = $"Preview — {set.Name} (revision {set.Revision})";
StartPosition = FormStartPosition.CenterParent;
Size = new System.Drawing.Size(1100, 800);
MinimumSize = new System.Drawing.Size(400, 300);
ShowIcon = false;
ShowInTaskbar = false;
}
internal DrawingSetPreviewControl Preview => preview;
/// <summary>Completes once every member has been read and shown; for tests.</summary>
internal Task Loaded { get; private set; } = Task.CompletedTask;
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
Loaded = LoadAsync();
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
cancel.Cancel();
base.OnFormClosed(e);
}
protected override void Dispose(bool disposing)
{
if (disposing)
cancel.Dispose();
base.Dispose(disposing);
}
internal async Task LoadAsync()
{
var members = set.Members;
if (members.Count == 0)
{
preview.SetInfo("This set has no drawings.");
return;
}
var token = cancel.Token;
var drawings = new Drawing[members.Count];
var failed = 0;
preview.SetInfo($"Loading {members.Count} drawings...");
using var gate = new SemaphoreSlim(Concurrency);
var reads = members.Select(async (member, i) =>
{
if (member.Drawing == null)
{
Interlocked.Increment(ref failed);
return;
}
await gate.WaitAsync(token);
try
{
drawings[i] = await load(member.Drawing, token);
if (!DrawingSetPreviewControl.CanShow(drawings[i]))
Interlocked.Increment(ref failed);
}
catch (Exception) when (!token.IsCancellationRequested)
{
// The preview is best-effort: a drawing that fails to load is counted and skipped.
Interlocked.Increment(ref failed);
}
finally
{
gate.Release();
}
}).ToList();
try
{
await Task.WhenAll(reads);
}
catch (OperationCanceledException)
{
return;
}
if (IsDisposed || token.IsCancellationRequested)
return;
preview.ShowDrawings(members.Select((member, i) => (drawings[i], Label(member))));
preview.SetInfo(Header(members, failed).ToArray());
}
private static string Label(DrawingSetMember member) =>
$"{member.Drawing?.Name} ×{member.Quantity}";
private IEnumerable<string> Header(IReadOnlyList<DrawingSetMember> members, int failed)
{
var parts = members.Sum(m => m.Quantity);
yield return $"{members.Count} drawings, {parts} parts — all shown at the same scale";
var units = members.Where(m => m.Drawing != null).Select(m => m.Drawing.Units).Distinct().ToList();
if (units.Count > 1)
yield return "This set mixes inches and millimeters; drawings in different units are not to scale with each other.";
if (failed > 0)
yield return $"{failed} drawing{(failed == 1 ? "" : "s")} could not be loaded and {(failed == 1 ? "is" : "are")} not shown.";
}
}
}
+2
View File
@@ -118,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
{