Compare commits

...

3 Commits

Author SHA1 Message Date
aj 8dc12972f5 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>
2026-03-19 21:44:21 -04:00
aj 8a0ebf8c18 feat(ui): improve BestFitViewerForm navigation and reduce flicker
Add third row (5x3 grid, 15 items/page), remove 50-result cap so all
candidates are pageable, start maximized, replace page label with
editable textbox for direct page entry, center nav controls, and
eliminate flicker on page change via DoubleBuffered + WM_SETREDRAW.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:23:20 -04:00
aj c552372f81 fix(core): copy-on-write for shared Program in tiled parts
CloneAtOffset shares the Program instance for tiling performance,
but rotating a part on the plate mutated the shared Program, causing
all parts from the same tile template to rotate together.

Added ownsProgram flag with EnsureOwnedProgram() that clones the
Program before first mutation, preserving tiling performance while
making user rotations independent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:10:20 -04:00
6 changed files with 305 additions and 79 deletions
+13
View File
@@ -20,6 +20,7 @@ namespace OpenNest
public class Part : IPart, IBoundable
{
private Vector location;
private bool ownsProgram;
public readonly Drawing BaseDrawing;
@@ -32,6 +33,7 @@ namespace OpenNest
{
BaseDrawing = baseDrawing;
Program = baseDrawing.Program.Clone() as Program;
ownsProgram = true;
this.location = location;
UpdateBounds();
}
@@ -67,6 +69,7 @@ namespace OpenNest
/// <param name="angle">Angle of rotation in radians.</param>
public void Rotate(double angle)
{
EnsureOwnedProgram();
Program.Rotate(angle);
location = Location.Rotate(angle);
UpdateBounds();
@@ -79,6 +82,7 @@ namespace OpenNest
/// <param name="origin">The origin to rotate the part around.</param>
public void Rotate(double angle, Vector origin)
{
EnsureOwnedProgram();
Program.Rotate(angle);
location = Location.Rotate(angle, origin);
UpdateBounds();
@@ -222,6 +226,15 @@ namespace OpenNest
return part;
}
private void EnsureOwnedProgram()
{
if (!ownsProgram)
{
Program = Program.Clone() as Program;
ownsProgram = true;
}
}
private Part(Drawing baseDrawing, Program program, Vector location, Box boundingBox)
{
BaseDrawing = baseDrawing;
+27 -2
View File
@@ -9,9 +9,16 @@ namespace OpenNest.Controls
public class BestFitCell : PlateView
{
private string[] metadataLines;
private Color? partColor;
public BestFitResult Result { get; set; }
public Color? PartColor
{
get => partColor;
set => partColor = value;
}
public BestFitCell(ColorScheme colorScheme)
: base(colorScheme)
{
@@ -22,6 +29,8 @@ namespace OpenNest.Controls
AllowZoom = false;
AllowDrop = false;
Cursor = Cursors.Hand;
Plate.PartAdded += (s, e) => ApplyPartColor();
}
public void SetMetadata(BestFitResult result, int rank)
@@ -59,13 +68,22 @@ namespace OpenNest.Controls
PaintMetadata(e.Graphics);
}
private void ApplyPartColor()
{
if (!partColor.HasValue)
return;
foreach (var lp in parts)
lp.Color = partColor.Value;
}
private void PaintMetadata(Graphics g)
{
if (metadataLines == null)
return;
var font = Font;
var brush = Brushes.White;
var textColor = IsDarkBackground() ? Brushes.White : Brushes.Black;
var lineHeight = font.GetHeight(g) + 1;
var y = 2f;
@@ -74,9 +92,16 @@ namespace OpenNest.Controls
if (line.Length == 0)
continue;
g.DrawString(line, font, brush, 2, y);
g.DrawString(line, font, textColor, 2, y);
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 Action currentAction;
private Action previousAction;
private List<LayoutPart> parts;
protected List<LayoutPart> parts;
private List<LayoutPart> stationaryParts = new List<LayoutPart>();
private List<LayoutPart> activeParts = new List<LayoutPart>();
private Point middleMouseDownPoint;
+66 -19
View File
@@ -14,10 +14,15 @@ namespace OpenNest.Forms
private void InitializeComponent()
{
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.btnPrev = new System.Windows.Forms.Button();
this.btnNext = new System.Windows.Forms.Button();
this.lblPage = new System.Windows.Forms.Label();
this.txtPage = new System.Windows.Forms.TextBox();
this.lblPageCount = new System.Windows.Forms.Label();
this.toolbarPanel.SuspendLayout();
this.navPanel.SuspendLayout();
this.SuspendLayout();
//
@@ -30,18 +35,47 @@ 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.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.RowCount = 2;
this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 50F));
this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 50F));
this.gridPanel.Size = new System.Drawing.Size(1200, 764);
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.34F));
this.gridPanel.RowStyles.Add(new System.Windows.Forms.RowStyle(System.Windows.Forms.SizeType.Percent, 33.33F));
this.gridPanel.Size = new System.Drawing.Size(1200, 732);
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
//
this.navPanel.Controls.Add(this.btnPrev);
this.navPanel.Controls.Add(this.lblPage);
this.navPanel.Controls.Add(this.txtPage);
this.navPanel.Controls.Add(this.lblPageCount);
this.navPanel.Controls.Add(this.btnNext);
this.navPanel.Dock = System.Windows.Forms.DockStyle.Bottom;
this.navPanel.Location = new System.Drawing.Point(0, 764);
@@ -52,30 +86,35 @@ namespace OpenNest.Forms
// btnPrev
//
this.btnPrev.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
this.btnPrev.Location = new System.Drawing.Point(4, 4);
this.btnPrev.Name = "btnPrev";
this.btnPrev.Size = new System.Drawing.Size(80, 28);
this.btnPrev.TabIndex = 0;
this.btnPrev.Text = "< Prev";
this.btnPrev.Click += new System.EventHandler(this.btnPrev_Click);
//
// lblPage
// txtPage
//
this.lblPage.Dock = System.Windows.Forms.DockStyle.Fill;
this.lblPage.Name = "lblPage";
this.lblPage.Size = new System.Drawing.Size(1200, 36);
this.lblPage.TabIndex = 1;
this.lblPage.Text = "Page 1 / 1";
this.lblPage.TextAlign = System.Drawing.ContentAlignment.MiddleCenter;
this.txtPage.Name = "txtPage";
this.txtPage.Size = new System.Drawing.Size(40, 20);
this.txtPage.TabIndex = 1;
this.txtPage.Text = "1";
this.txtPage.TextAlign = System.Windows.Forms.HorizontalAlignment.Center;
this.txtPage.KeyDown += new System.Windows.Forms.KeyEventHandler(this.txtPage_KeyDown);
//
// lblPageCount
//
this.lblPageCount.Name = "lblPageCount";
this.lblPageCount.Size = new System.Drawing.Size(50, 28);
this.lblPageCount.TabIndex = 2;
this.lblPageCount.Text = "/ 1";
this.lblPageCount.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
//
// btnNext
//
this.btnNext.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
this.btnNext.FlatStyle = System.Windows.Forms.FlatStyle.Flat;
this.btnNext.Location = new System.Drawing.Point(1116, 4);
this.btnNext.Name = "btnNext";
this.btnNext.Size = new System.Drawing.Size(80, 28);
this.btnNext.TabIndex = 2;
this.btnNext.TabIndex = 3;
this.btnNext.Text = "Next >";
this.btnNext.Click += new System.EventHandler(this.btnNext_Click);
//
@@ -85,19 +124,27 @@ namespace OpenNest.Forms
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(1200, 800);
this.Controls.Add(this.gridPanel);
this.Controls.Add(this.toolbarPanel);
this.Controls.Add(this.navPanel);
this.KeyPreview = true;
this.Name = "BestFitViewerForm";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "Best-Fit Viewer";
this.WindowState = System.Windows.Forms.FormWindowState.Maximized;
this.toolbarPanel.ResumeLayout(false);
this.navPanel.ResumeLayout(false);
this.navPanel.PerformLayout();
this.ResumeLayout(false);
}
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.Button btnPrev;
private System.Windows.Forms.Button btnNext;
private System.Windows.Forms.Label lblPage;
private System.Windows.Forms.TextBox txtPage;
private System.Windows.Forms.Label lblPageCount;
}
}
+194 -51
View File
@@ -1,25 +1,38 @@
using OpenNest.Collections;
using OpenNest.Controls;
using OpenNest.Engine.BestFit;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace OpenNest.Forms
{
public partial class BestFitViewerForm : Form
{
private const int WM_SETREDRAW = 0x000B;
[DllImport("user32.dll")]
private static extern IntPtr SendMessage(IntPtr hWnd, int msg, IntPtr wParam, IntPtr lParam);
private const int Columns = 5;
private const int Rows = 2;
private const int Rows = 3;
private const int ItemsPerPage = Columns * Rows;
private const int MaxResults = 50;
private static readonly Color KeptColor = Color.FromArgb(0, 0, 100);
private static readonly Color DroppedColor = Color.FromArgb(100, 0, 0);
private static readonly Color KeptBackground = Color.FromArgb(240, 240, 240);
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 Drawing activeDrawing;
private List<BestFitResult> results;
private int totalResults;
private int keptCount;
@@ -27,29 +40,37 @@ namespace OpenNest.Forms
private double totalSeconds;
private int currentPage;
private int pageCount;
private CancellationTokenSource computeCts;
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.activeDrawing = this.drawings[0];
DoubleBuffered = true;
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;
}
private void BestFitViewerForm_Shown(object sender, System.EventArgs e)
private void BestFitViewerForm_Shown(object sender, EventArgs e)
{
Cursor = Cursors.WaitCursor;
try
{
ComputeResults();
ShowPage(0);
}
finally
{
Cursor = Cursors.Default;
}
LoadResultsAsync();
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
computeCts?.Cancel();
base.OnFormClosed(e);
}
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
@@ -72,28 +93,97 @@ namespace OpenNest.Forms
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 all = BestFitCache.GetOrCompute(
drawing, plate.Size.Length, plate.Size.Width, plate.PartSpacing);
var all = BestFitCache.GetOrCompute(drawing, length, width, spacing);
computeSeconds = sw.ElapsedMilliseconds / 1000.0;
totalResults = all.Count;
keptCount = 0;
var computeMs = sw.ElapsedMilliseconds;
var total = all.Count;
var kept = 0;
foreach (var r in all)
{
if (r.Keep) keptCount++;
if (r.Keep) kept++;
}
var count = System.Math.Min(totalResults, MaxResults);
results = all.GetRange(0, count);
pageCount = System.Math.Max(1, (int)System.Math.Ceiling(count / (double)ItemsPerPage));
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)
@@ -102,35 +192,64 @@ namespace OpenNest.Forms
var start = page * ItemsPerPage;
var count = System.Math.Min(ItemsPerPage, results.Count - start);
gridPanel.SuspendLayout();
gridPanel.Controls.Clear();
gridPanel.RowCount = Rows;
gridPanel.RowStyles.Clear();
for (var i = 0; i < Rows; i++)
gridPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 100f / Rows));
for (var i = 0; i < count; i++)
SendMessage(gridPanel.Handle, WM_SETREDRAW, IntPtr.Zero, IntPtr.Zero);
try
{
var result = results[start + i];
var cell = CreateCell(result, drawing, start + i + 1);
gridPanel.Controls.Add(cell, i % Columns, i / Columns);
}
gridPanel.SuspendLayout();
gridPanel.Controls.Clear();
gridPanel.ResumeLayout(true);
gridPanel.RowCount = Rows;
gridPanel.RowStyles.Clear();
for (var i = 0; i < Rows; i++)
gridPanel.RowStyles.Add(new RowStyle(SizeType.Percent, 100f / Rows));
for (var i = 0; i < count; i++)
{
var result = results[start + i];
var cell = CreateCell(result, activeDrawing, start + i + 1);
gridPanel.Controls.Add(cell, i % Columns, i / Columns);
}
gridPanel.ResumeLayout(true);
}
finally
{
SendMessage(gridPanel.Handle, WM_SETREDRAW, (IntPtr)1, IntPtr.Zero);
gridPanel.Invalidate(true);
}
btnPrev.Enabled = currentPage > 0;
btnNext.Enabled = currentPage < pageCount - 1;
lblPage.Text = string.Format("Page {0} / {1}", currentPage + 1, pageCount);
txtPage.Text = (currentPage + 1).ToString();
lblPageCount.Text = string.Format("/ {0}", pageCount);
Text = string.Format("Best-Fit Viewer — {0} candidates ({1} kept) | Compute: {2:F1}s | Total: {3:F1}s | Showing {4}-{5} of {6}",
totalResults, keptCount, computeSeconds, totalSeconds,
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)
{
@@ -139,14 +258,28 @@ namespace OpenNest.Forms
ShowPage(newPage);
}
private void txtPage_KeyDown(object sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Enter)
{
e.SuppressKeyPress = true;
if (int.TryParse(txtPage.Text, out var page) && page >= 1 && page <= pageCount)
ShowPage(page - 1);
else
txtPage.Text = (currentPage + 1).ToString();
}
}
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
{
BackgroundColor = bgColor,
LayoutOutlineColor = bgColor,
LayoutOutlineColor = Color.Gray,
LayoutFillColor = bgColor,
BoundingBoxColor = bgColor,
RapidColor = Color.DodgerBlue,
@@ -155,10 +288,11 @@ namespace OpenNest.Forms
};
var cell = new BestFitCell(colorScheme);
cell.PartColor = partColor;
cell.Dock = DockStyle.Fill;
cell.Plate.Size = new Geometry.Size(
result.BoundingWidth,
result.BoundingHeight);
result.BoundingHeight,
result.BoundingWidth);
var parts = result.BuildParts(drawing);
@@ -176,5 +310,14 @@ namespace OpenNest.Forms
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;
var plate = activeForm.PlateView.Plate;
var drawing = activeForm.Nest.Drawings.Count > 0
? activeForm.Nest.Drawings.First()
: null;
var drawings = activeForm.Nest.Drawings;
if (drawing == null)
if (drawings.Count == 0)
{
MessageBox.Show("No drawings available.", "Best-Fit Viewer",
MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
using (var form = new BestFitViewerForm(drawing, plate))
using (var form = new BestFitViewerForm(drawings, plate))
{
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);
}
}