mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-29 10:11:21 -04:00
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:
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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user