feat(ui): add drawing selector, color scheme, and async loading to BestFitViewer

Add drawing dropdown to switch between drawings without reopening the
form. Change color scheme to light backgrounds with blue/red part fills
and auto-detect text color. Fix swapped bounding box width/length. Run
best-fit computation on a background thread so the UI stays responsive
during long calculations, with cancellation on drawing switch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 21:44:21 -04:00
parent 8a0ebf8c18
commit 8dc12972f5
5 changed files with 219 additions and 53 deletions
+27 -2
View File
@@ -9,9 +9,16 @@ namespace OpenNest.Controls
public class BestFitCell : PlateView public class BestFitCell : PlateView
{ {
private string[] metadataLines; private string[] metadataLines;
private Color? partColor;
public BestFitResult Result { get; set; } public BestFitResult Result { get; set; }
public Color? PartColor
{
get => partColor;
set => partColor = value;
}
public BestFitCell(ColorScheme colorScheme) public BestFitCell(ColorScheme colorScheme)
: base(colorScheme) : base(colorScheme)
{ {
@@ -22,6 +29,8 @@ namespace OpenNest.Controls
AllowZoom = false; AllowZoom = false;
AllowDrop = false; AllowDrop = false;
Cursor = Cursors.Hand; Cursor = Cursors.Hand;
Plate.PartAdded += (s, e) => ApplyPartColor();
} }
public void SetMetadata(BestFitResult result, int rank) public void SetMetadata(BestFitResult result, int rank)
@@ -59,13 +68,22 @@ namespace OpenNest.Controls
PaintMetadata(e.Graphics); PaintMetadata(e.Graphics);
} }
private void ApplyPartColor()
{
if (!partColor.HasValue)
return;
foreach (var lp in parts)
lp.Color = partColor.Value;
}
private void PaintMetadata(Graphics g) private void PaintMetadata(Graphics g)
{ {
if (metadataLines == null) if (metadataLines == null)
return; return;
var font = Font; var font = Font;
var brush = Brushes.White; var textColor = IsDarkBackground() ? Brushes.White : Brushes.Black;
var lineHeight = font.GetHeight(g) + 1; var lineHeight = font.GetHeight(g) + 1;
var y = 2f; var y = 2f;
@@ -74,9 +92,16 @@ namespace OpenNest.Controls
if (line.Length == 0) if (line.Length == 0)
continue; continue;
g.DrawString(line, font, brush, 2, y); g.DrawString(line, font, textColor, 2, y);
y += lineHeight; y += lineHeight;
} }
} }
private bool IsDarkBackground()
{
var bg = ColorScheme.BackgroundColor;
var brightness = bg.R * 0.299 + bg.G * 0.587 + bg.B * 0.114;
return brightness < 128;
}
} }
} }
+1 -1
View File
@@ -30,7 +30,7 @@ namespace OpenNest.Controls
private Plate plate; private Plate plate;
private Action currentAction; private Action currentAction;
private Action previousAction; private Action previousAction;
private List<LayoutPart> parts; protected List<LayoutPart> parts;
private List<LayoutPart> stationaryParts = new List<LayoutPart>(); private List<LayoutPart> stationaryParts = new List<LayoutPart>();
private List<LayoutPart> activeParts = new List<LayoutPart>(); private List<LayoutPart> activeParts = new List<LayoutPart>();
private Point middleMouseDownPoint; private Point middleMouseDownPoint;
+38 -10
View File
@@ -14,11 +14,15 @@ namespace OpenNest.Forms
private void InitializeComponent() private void InitializeComponent()
{ {
this.gridPanel = new System.Windows.Forms.TableLayoutPanel(); this.gridPanel = new System.Windows.Forms.TableLayoutPanel();
this.toolbarPanel = new System.Windows.Forms.Panel();
this.lblDrawing = new System.Windows.Forms.Label();
this.cboDrawing = new System.Windows.Forms.ComboBox();
this.navPanel = new System.Windows.Forms.Panel(); this.navPanel = new System.Windows.Forms.Panel();
this.btnPrev = new System.Windows.Forms.Button(); this.btnPrev = new System.Windows.Forms.Button();
this.btnNext = new System.Windows.Forms.Button(); this.btnNext = new System.Windows.Forms.Button();
this.txtPage = new System.Windows.Forms.TextBox(); this.txtPage = new System.Windows.Forms.TextBox();
this.lblPageCount = new System.Windows.Forms.Label(); this.lblPageCount = new System.Windows.Forms.Label();
this.toolbarPanel.SuspendLayout();
this.navPanel.SuspendLayout(); this.navPanel.SuspendLayout();
this.SuspendLayout(); this.SuspendLayout();
// //
@@ -31,15 +35,42 @@ namespace OpenNest.Forms
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F)); this.gridPanel.ColumnStyles.Add(new System.Windows.Forms.ColumnStyle(System.Windows.Forms.SizeType.Percent, 20F));
this.gridPanel.Dock = System.Windows.Forms.DockStyle.Fill; this.gridPanel.Dock = System.Windows.Forms.DockStyle.Fill;
this.gridPanel.Location = new System.Drawing.Point(0, 0); this.gridPanel.Location = new System.Drawing.Point(0, 32);
this.gridPanel.Name = "gridPanel"; this.gridPanel.Name = "gridPanel";
this.gridPanel.RowCount = 3; this.gridPanel.RowCount = 3;
this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33F)); this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33F));
this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.34F)); this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.34F));
this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33F)); this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33F));
this.gridPanel.Size = new System.Drawing.Size(1200, 764); this.gridPanel.Size = new System.Drawing.Size(1200, 732);
this.gridPanel.TabIndex = 0; this.gridPanel.TabIndex = 0;
// //
// toolbarPanel
//
this.toolbarPanel.Controls.Add(this.lblDrawing);
this.toolbarPanel.Controls.Add(this.cboDrawing);
this.toolbarPanel.Dock = System.Windows.Forms.DockStyle.Top;
this.toolbarPanel.Location = new System.Drawing.Point(0, 0);
this.toolbarPanel.Name = "toolbarPanel";
this.toolbarPanel.Size = new System.Drawing.Size(1200, 32);
this.toolbarPanel.TabIndex = 2;
//
// lblDrawing
//
this.lblDrawing.Location = new System.Drawing.Point(6, 0);
this.lblDrawing.Name = "lblDrawing";
this.lblDrawing.Size = new System.Drawing.Size(55, 32);
this.lblDrawing.TabIndex = 0;
this.lblDrawing.Text = "Drawing:";
this.lblDrawing.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// cboDrawing
//
this.cboDrawing.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
this.cboDrawing.Location = new System.Drawing.Point(64, 5);
this.cboDrawing.Name = "cboDrawing";
this.cboDrawing.Size = new System.Drawing.Size(250, 21);
this.cboDrawing.TabIndex = 1;
//
// navPanel // navPanel
// //
this.navPanel.Controls.Add(this.btnPrev); this.navPanel.Controls.Add(this.btnPrev);
@@ -54,9 +85,7 @@ namespace OpenNest.Forms
// //
// btnPrev // btnPrev
// //
this.btnPrev.Anchor = System.Windows.Forms.AnchorStyles.Top;
this.btnPrev.FlatStyle = System.Windows.Forms.FlatStyle.Flat; this.btnPrev.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
this.btnPrev.Location = new System.Drawing.Point(480, 4);
this.btnPrev.Name = "btnPrev"; this.btnPrev.Name = "btnPrev";
this.btnPrev.Size = new System.Drawing.Size(80, 28); this.btnPrev.Size = new System.Drawing.Size(80, 28);
this.btnPrev.TabIndex = 0; this.btnPrev.TabIndex = 0;
@@ -65,8 +94,6 @@ namespace OpenNest.Forms
// //
// txtPage // txtPage
// //
this.txtPage.Anchor = System.Windows.Forms.AnchorStyles.Top;
this.txtPage.Location = new System.Drawing.Point(564, 7);
this.txtPage.Name = "txtPage"; this.txtPage.Name = "txtPage";
this.txtPage.Size = new System.Drawing.Size(40, 20); this.txtPage.Size = new System.Drawing.Size(40, 20);
this.txtPage.TabIndex = 1; this.txtPage.TabIndex = 1;
@@ -76,8 +103,6 @@ namespace OpenNest.Forms
// //
// lblPageCount // lblPageCount
// //
this.lblPageCount.Anchor = System.Windows.Forms.AnchorStyles.Top;
this.lblPageCount.Location = new System.Drawing.Point(606, 4);
this.lblPageCount.Name = "lblPageCount"; this.lblPageCount.Name = "lblPageCount";
this.lblPageCount.Size = new System.Drawing.Size(50, 28); this.lblPageCount.Size = new System.Drawing.Size(50, 28);
this.lblPageCount.TabIndex = 2; this.lblPageCount.TabIndex = 2;
@@ -86,9 +111,7 @@ namespace OpenNest.Forms
// //
// btnNext // btnNext
// //
this.btnNext.Anchor = System.Windows.Forms.AnchorStyles.Top;
this.btnNext.FlatStyle = System.Windows.Forms.FlatStyle.Flat; this.btnNext.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
this.btnNext.Location = new System.Drawing.Point(660, 4);
this.btnNext.Name = "btnNext"; this.btnNext.Name = "btnNext";
this.btnNext.Size = new System.Drawing.Size(80, 28); this.btnNext.Size = new System.Drawing.Size(80, 28);
this.btnNext.TabIndex = 3; this.btnNext.TabIndex = 3;
@@ -101,18 +124,23 @@ namespace OpenNest.Forms
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(1200, 800); this.ClientSize = new System.Drawing.Size(1200, 800);
this.Controls.Add(this.gridPanel); this.Controls.Add(this.gridPanel);
this.Controls.Add(this.toolbarPanel);
this.Controls.Add(this.navPanel); this.Controls.Add(this.navPanel);
this.KeyPreview = true; this.KeyPreview = true;
this.Name = "BestFitViewerForm"; this.Name = "BestFitViewerForm";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent; this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "Best-Fit Viewer"; this.Text = "Best-Fit Viewer";
this.WindowState = System.Windows.Forms.FormWindowState.Maximized; this.WindowState = System.Windows.Forms.FormWindowState.Maximized;
this.toolbarPanel.ResumeLayout(false);
this.navPanel.ResumeLayout(false); this.navPanel.ResumeLayout(false);
this.navPanel.PerformLayout(); this.navPanel.PerformLayout();
this.ResumeLayout(false); this.ResumeLayout(false);
} }
private System.Windows.Forms.TableLayoutPanel gridPanel; private System.Windows.Forms.TableLayoutPanel gridPanel;
private System.Windows.Forms.Panel toolbarPanel;
private System.Windows.Forms.Label lblDrawing;
private System.Windows.Forms.ComboBox cboDrawing;
private System.Windows.Forms.Panel navPanel; private System.Windows.Forms.Panel navPanel;
private System.Windows.Forms.Button btnPrev; private System.Windows.Forms.Button btnPrev;
private System.Windows.Forms.Button btnNext; private System.Windows.Forms.Button btnNext;
+149 -34
View File
@@ -1,10 +1,14 @@
using OpenNest.Collections;
using OpenNest.Controls; using OpenNest.Controls;
using OpenNest.Engine.BestFit; using OpenNest.Engine.BestFit;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Drawing; using System.Drawing;
using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms; using System.Windows.Forms;
namespace OpenNest.Forms namespace OpenNest.Forms
@@ -20,12 +24,15 @@ namespace OpenNest.Forms
private const int Rows = 3; private const int Rows = 3;
private const int ItemsPerPage = Columns * Rows; private const int ItemsPerPage = Columns * Rows;
private static readonly Color KeptColor = Color.FromArgb(0, 0, 100); private static readonly Color KeptBackground = Color.FromArgb(240, 240, 240);
private static readonly Color DroppedColor = Color.FromArgb(100, 0, 0); private static readonly Color DroppedBackground = Color.FromArgb(255, 235, 235);
private static readonly Color KeptPartColor = Color.FromArgb(50, 120, 190);
private static readonly Color DroppedPartColor = Color.FromArgb(180, 80, 80);
private readonly Drawing drawing; private readonly List<Drawing> drawings;
private readonly Plate plate; private readonly Plate plate;
private Drawing activeDrawing;
private List<BestFitResult> results; private List<BestFitResult> results;
private int totalResults; private int totalResults;
private int keptCount; private int keptCount;
@@ -33,30 +40,37 @@ namespace OpenNest.Forms
private double totalSeconds; private double totalSeconds;
private int currentPage; private int currentPage;
private int pageCount; private int pageCount;
private CancellationTokenSource computeCts;
public BestFitResult SelectedResult { get; private set; } public BestFitResult SelectedResult { get; private set; }
public Drawing SelectedDrawing => activeDrawing;
public BestFitViewerForm(Drawing drawing, Plate plate) public BestFitViewerForm(DrawingCollection drawings, Plate plate)
{ {
this.drawing = drawing; this.drawings = drawings.ToList();
this.plate = plate; this.plate = plate;
this.activeDrawing = this.drawings[0];
DoubleBuffered = true; DoubleBuffered = true;
InitializeComponent(); InitializeComponent();
foreach (var d in drawings)
cboDrawing.Items.Add(d.Name);
cboDrawing.SelectedIndex = 0;
cboDrawing.SelectedIndexChanged += cboDrawing_SelectedIndexChanged;
navPanel.SizeChanged += (s, ev) => CenterNavControls();
Shown += BestFitViewerForm_Shown; Shown += BestFitViewerForm_Shown;
} }
private void BestFitViewerForm_Shown(object sender, System.EventArgs e) private void BestFitViewerForm_Shown(object sender, EventArgs e)
{ {
Cursor = Cursors.WaitCursor; LoadResultsAsync();
try }
{
ComputeResults(); protected override void OnFormClosed(FormClosedEventArgs e)
ShowPage(0); {
} computeCts?.Cancel();
finally base.OnFormClosed(e);
{
Cursor = Cursors.Default;
}
} }
protected override bool ProcessCmdKey(ref Message msg, Keys keyData) protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
@@ -79,27 +93,97 @@ namespace OpenNest.Forms
return base.ProcessCmdKey(ref msg, keyData); return base.ProcessCmdKey(ref msg, keyData);
} }
private void ComputeResults() private void cboDrawing_SelectedIndexChanged(object sender, EventArgs e)
{
var index = cboDrawing.SelectedIndex;
if (index < 0 || index >= drawings.Count)
return;
activeDrawing = drawings[index];
LoadResultsAsync();
}
private async void LoadResultsAsync()
{
computeCts?.Cancel();
var cts = new CancellationTokenSource();
computeCts = cts;
SetLoading(true);
try
{
var drawing = activeDrawing;
var length = plate.Size.Length;
var width = plate.Size.Width;
var spacing = plate.PartSpacing;
var result = await Task.Run(() => ComputeResults(drawing, length, width, spacing), cts.Token);
if (cts.Token.IsCancellationRequested)
return;
results = result.Results;
totalResults = result.TotalResults;
keptCount = result.KeptCount;
computeSeconds = result.ComputeSeconds;
totalSeconds = result.TotalSeconds;
pageCount = System.Math.Max(1, (int)System.Math.Ceiling(results.Count / (double)ItemsPerPage));
ShowPage(0);
}
catch (OperationCanceledException)
{
}
finally
{
if (cts == computeCts)
SetLoading(false);
}
}
private void SetLoading(bool loading)
{
Cursor = loading ? Cursors.WaitCursor : Cursors.Default;
cboDrawing.Enabled = !loading;
btnPrev.Enabled = !loading;
btnNext.Enabled = !loading;
txtPage.Enabled = !loading;
if (loading)
{
Text = "Best-Fit Viewer — Computing...";
gridPanel.SuspendLayout();
gridPanel.Controls.Clear();
gridPanel.ResumeLayout(true);
}
}
private static ComputeResult ComputeResults(Drawing drawing, double length, double width, double spacing)
{ {
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
var all = BestFitCache.GetOrCompute( var all = BestFitCache.GetOrCompute(drawing, length, width, spacing);
drawing, plate.Size.Length, plate.Size.Width, plate.PartSpacing);
computeSeconds = sw.ElapsedMilliseconds / 1000.0; var computeMs = sw.ElapsedMilliseconds;
totalResults = all.Count; var total = all.Count;
keptCount = 0; var kept = 0;
foreach (var r in all) foreach (var r in all)
{ {
if (r.Keep) keptCount++; if (r.Keep) kept++;
} }
results = all;
pageCount = System.Math.Max(1, (int)System.Math.Ceiling(results.Count / (double)ItemsPerPage));
sw.Stop(); sw.Stop();
totalSeconds = sw.Elapsed.TotalSeconds;
return new ComputeResult
{
Results = all,
TotalResults = total,
KeptCount = kept,
ComputeSeconds = computeMs / 1000.0,
TotalSeconds = sw.Elapsed.TotalSeconds
};
} }
private void ShowPage(int page) private void ShowPage(int page)
@@ -122,7 +206,7 @@ namespace OpenNest.Forms
for (var i = 0; i < count; i++) for (var i = 0; i < count; i++)
{ {
var result = results[start + i]; var result = results[start + i];
var cell = CreateCell(result, drawing, start + i + 1); var cell = CreateCell(result, activeDrawing, start + i + 1);
gridPanel.Controls.Add(cell, i % Columns, i / Columns); gridPanel.Controls.Add(cell, i % Columns, i / Columns);
} }
@@ -144,9 +228,28 @@ namespace OpenNest.Forms
start + 1, start + count, results.Count); start + 1, start + count, results.Count);
} }
private void btnPrev_Click(object sender, System.EventArgs e) => NavigatePage(-1); private void btnPrev_Click(object sender, EventArgs e) => NavigatePage(-1);
private void btnNext_Click(object sender, System.EventArgs e) => NavigatePage(1); private void btnNext_Click(object sender, EventArgs e) => NavigatePage(1);
private void CenterNavControls()
{
var gap = 6;
var groupWidth = btnPrev.Width + gap + txtPage.Width + gap + lblPageCount.Width + gap + btnNext.Width;
var x = (navPanel.Width - groupWidth) / 2;
var midY = navPanel.Height / 2;
btnPrev.Location = new Point(x, midY - btnPrev.Height / 2);
x += btnPrev.Width + gap;
txtPage.Location = new Point(x, midY - txtPage.Height / 2);
x += txtPage.Width + gap;
lblPageCount.Location = new Point(x, midY - lblPageCount.Height / 2);
x += lblPageCount.Width + gap;
btnNext.Location = new Point(x, midY - btnNext.Height / 2);
}
private void NavigatePage(int delta) private void NavigatePage(int delta)
{ {
@@ -169,12 +272,14 @@ namespace OpenNest.Forms
private BestFitCell CreateCell(BestFitResult result, Drawing drawing, int rank) private BestFitCell CreateCell(BestFitResult result, Drawing drawing, int rank)
{ {
var bgColor = result.Keep ? KeptColor : DroppedColor; var kept = result.Keep;
var bgColor = kept ? KeptBackground : DroppedBackground;
var partColor = kept ? KeptPartColor : DroppedPartColor;
var colorScheme = new ColorScheme var colorScheme = new ColorScheme
{ {
BackgroundColor = bgColor, BackgroundColor = bgColor,
LayoutOutlineColor = bgColor, LayoutOutlineColor = Color.Gray,
LayoutFillColor = bgColor, LayoutFillColor = bgColor,
BoundingBoxColor = bgColor, BoundingBoxColor = bgColor,
RapidColor = Color.DodgerBlue, RapidColor = Color.DodgerBlue,
@@ -183,10 +288,11 @@ namespace OpenNest.Forms
}; };
var cell = new BestFitCell(colorScheme); var cell = new BestFitCell(colorScheme);
cell.PartColor = partColor;
cell.Dock = DockStyle.Fill; cell.Dock = DockStyle.Fill;
cell.Plate.Size = new Geometry.Size( cell.Plate.Size = new Geometry.Size(
result.BoundingWidth, result.BoundingHeight,
result.BoundingHeight); result.BoundingWidth);
var parts = result.BuildParts(drawing); var parts = result.BuildParts(drawing);
@@ -204,5 +310,14 @@ namespace OpenNest.Forms
return cell; return cell;
} }
private struct ComputeResult
{
public List<BestFitResult> Results;
public int TotalResults;
public int KeptCount;
public double ComputeSeconds;
public double TotalSeconds;
}
} }
} }
+4 -6
View File
@@ -576,22 +576,20 @@ namespace OpenNest.Forms
return; return;
var plate = activeForm.PlateView.Plate; var plate = activeForm.PlateView.Plate;
var drawing = activeForm.Nest.Drawings.Count > 0 var drawings = activeForm.Nest.Drawings;
? activeForm.Nest.Drawings.First()
: null;
if (drawing == null) if (drawings.Count == 0)
{ {
MessageBox.Show("No drawings available.", "Best-Fit Viewer", MessageBox.Show("No drawings available.", "Best-Fit Viewer",
MessageBoxButtons.OK, MessageBoxIcon.Information); MessageBoxButtons.OK, MessageBoxIcon.Information);
return; return;
} }
using (var form = new BestFitViewerForm(drawing, plate)) using (var form = new BestFitViewerForm(drawings, plate))
{ {
if (form.ShowDialog(this) == DialogResult.OK && form.SelectedResult != null) if (form.ShowDialog(this) == DialogResult.OK && form.SelectedResult != null)
{ {
var parts = form.SelectedResult.BuildParts(drawing); var parts = form.SelectedResult.BuildParts(form.SelectedDrawing);
activeForm.PlateView.SetAction(typeof(ActionClone), parts); activeForm.PlateView.SetAction(typeof(ActionClone), parts);
} }
} }