diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
index e91fa5a..a7b2353 100644
--- a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
@@ -236,6 +236,10 @@ public sealed class CuttingPlanPlateResult
Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed);
}
+/// A diagnostic tied to captured plate and source part positions, not display text.
+public sealed record CuttingPlanDiagnosticRow(int PlateIndex, int PlateNumber, int? PartNumber,
+ int? OtherPartNumber, string Message);
+
///
/// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance.
///
@@ -263,6 +267,35 @@ public sealed class CuttingPlanProposal
public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply;
+ /// All findings, including ones hidden by a blocked plate's abbreviated summary.
+ public IReadOnlyList DiagnosticRows()
+ {
+ var rows = new List();
+ for (var index = 0; index < Plates.Count; index++)
+ {
+ var plate = Plates[index];
+ if (plate.Overlap != null)
+ {
+ foreach (var pair in plate.Overlap.Pairs)
+ rows.Add(new(index, plate.PlateNumber, pair.PartAId + 1, pair.PartBId + 1,
+ $"Part {pair.PartAId + 1}{Name(pair.PartAName)} overlaps part "
+ + $"{pair.PartBId + 1}{Name(pair.PartBName)}."));
+ foreach (var issue in plate.Overlap.Issues)
+ rows.Add(new(index, plate.PlateNumber, issue.PartAId + 1,
+ issue.PartBId is int other ? other + 1 : null,
+ $"Overlap check incomplete for part {issue.PartAId + 1}"
+ + (issue.PartBId is int second ? $" and part {second + 1}" : string.Empty)
+ + $": {issue.Message}"));
+ }
+ foreach (var finding in plate.Result.Findings)
+ rows.Add(new(index, plate.PlateNumber,
+ finding.SourceOrdinal is int part ? part + 1 : null,
+ finding.OtherSourceOrdinal is int other ? other + 1 : null,
+ DescribeFinding(finding)));
+ }
+ return rows;
+ }
+
///
/// Installs every plate's replayed proposal through , on the
/// thread that owns the plates. Only after every plate is applied does each one keep an owned copy
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs
index c3604a7..2a990de 100644
--- a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs
+++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs
@@ -90,6 +90,10 @@ public class CuttingPlanBatchTests
var text = string.Join("\n", proposal.Describe("in"));
Assert.Contains("Plate 1: blocked: parts overlap or could not be checked for overlap.", text);
Assert.Contains("- Part 1 (first) overlaps part 2 (second).", text);
+ var row = Assert.Single(proposal.DiagnosticRows());
+ Assert.Equal((0, 1, 1, 2),
+ (row.PlateIndex, row.PlateNumber, row.PartNumber, row.OtherPartNumber));
+ Assert.Contains("overlaps part 2", row.Message);
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
diff --git a/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs b/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs
index 8e300b0..057e7b3 100644
--- a/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs
+++ b/OpenNest.WinForms.Tests/Forms/CuttingPlanFormTests.cs
@@ -59,10 +59,12 @@ public class CuttingPlanFormTests
Assert.False(Control(form, "applyButton").Enabled);
var summary = Control(form, "summaryBox").Text;
Assert.Contains("Plate 1: blocked", summary);
- Assert.Contains("Part 1 (locked)", summary);
+ var warnings = Control(form, "warningGrid");
+ Assert.Contains(warnings.Rows.Cast(), row =>
+ row.Cells[3].Value?.ToString()?.Contains("Part 1 (locked)") == true);
// A refused plate is never previewed; the editor already shows its part numbers.
Assert.False(Field(form, "preview").Visible);
- Assert.StartsWith("No preview", Control(form, "previewLabel").Text);
+ Assert.Contains("no current, usable preview", Control(form, "previewLabel").Text);
Assert.Null(view.Plate.CuttingParameters);
});
@@ -146,6 +148,7 @@ public class CuttingPlanFormTests
Assert.Equal(CuttingCommitStatus.Stale, form.CommitResult!.Status);
Assert.Contains("changed after planning", Control(form, "summaryBox").Text);
Assert.False(Control(form, "applyButton").Enabled);
+ Assert.Empty(Control(form, "warningGrid").Rows.Cast());
Assert.Equal(programs, view.Plate.Parts.Select(part => part.Program));
Assert.Null(view.Plate.CuttingParameters);
@@ -247,13 +250,23 @@ public class CuttingPlanFormTests
Assert.False(Control(form, "applyButton").Enabled);
Assert.True(Field(form, "preview").Visible);
Assert.Contains("UNVERIFIED", Control(form, "previewLabel").Text);
+ var grid = Control(form, "warningGrid");
+ Assert.NotEmpty(grid.Rows.Cast());
+ Assert.Contains(grid.Rows.Cast(), row =>
+ row.Cells[3].Value?.ToString()?.Contains("Material boundaries") == true);
+ var partRow = grid.Rows.Cast().First(row => row.Cells[1].Value is int);
+ partRow.Selected = true;
+ Assert.Contains("affected part extent", Control(form, "previewLabel").Text);
+ Assert.True(Field(form, "preview").Visible);
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
Assert.Null(form.CommitResult);
Assert.False(part.HasManualLeadIns);
accept.Checked = true;
Assert.True(Control(form, "applyButton").Enabled);
Invoke(form, "PlanButton_Click", null, EventArgs.Empty);
+ Assert.Empty(grid.Rows.Cast()); // No stale warning targets while replanning.
WaitForPlan(form);
+ Assert.NotEmpty(grid.Rows.Cast());
Assert.False(accept.Checked);
Assert.False(Control(form, "applyButton").Enabled);
accept.Checked = true;
diff --git a/OpenNest/Forms/CuttingPlanForm.cs b/OpenNest/Forms/CuttingPlanForm.cs
index 51220e2..c109d2e 100644
--- a/OpenNest/Forms/CuttingPlanForm.cs
+++ b/OpenNest/Forms/CuttingPlanForm.cs
@@ -1,5 +1,5 @@
using System;
-using System.Collections.Generic;
+using System.Drawing;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
@@ -25,6 +25,8 @@ public partial class CuttingPlanForm : Form
private readonly Func isOperationBusy;
private readonly string unit;
private readonly PlateView preview;
+ private readonly DataGridView warningGrid;
+ private bool populatingWarnings;
private readonly SynchronizationContext uiContext;
private readonly string previewText;
private CuttingParameters parameters;
@@ -57,11 +59,54 @@ public partial class CuttingPlanForm : Form
this.isOperationBusy = isOperationBusy;
unit = UnitsHelper.GetShortString(nest.Units);
InitializeComponent();
- // Best-effort acceptance must be able to display every warning, even on large jobs.
+ StartPosition = FormStartPosition.Manual;
+ warningGrid = new DataGridView
+ {
+ Name = "warningGrid",
+ Dock = DockStyle.Fill,
+ ReadOnly = true,
+ AllowUserToAddRows = false,
+ AllowUserToDeleteRows = false,
+ MultiSelect = false,
+ SelectionMode = DataGridViewSelectionMode.FullRowSelect,
+ AutoGenerateColumns = false,
+ RowHeadersVisible = false,
+ BackgroundColor = SystemColors.Window,
+ };
+ warningGrid.Columns.Add(new DataGridViewTextBoxColumn
+ {
+ HeaderText = "Plate",
+ Width = 58,
+ SortMode = DataGridViewColumnSortMode.NotSortable
+ });
+ warningGrid.Columns.Add(new DataGridViewTextBoxColumn
+ {
+ HeaderText = "Part",
+ Width = 58,
+ SortMode = DataGridViewColumnSortMode.NotSortable
+ });
+ warningGrid.Columns.Add(new DataGridViewTextBoxColumn
+ {
+ HeaderText = "With",
+ Width = 58,
+ SortMode = DataGridViewColumnSortMode.NotSortable
+ });
+ warningGrid.Columns.Add(new DataGridViewTextBoxColumn
+ {
+ HeaderText = "Warning / finding",
+ AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill,
+ SortMode = DataGridViewColumnSortMode.NotSortable
+ });
+ warningGrid.SelectionChanged += WarningGrid_SelectionChanged;
+ var findingsLayout = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1 };
+ findingsLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 30));
+ findingsLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 70));
+ summaryBox.Dock = DockStyle.Fill;
+ findingsLayout.Controls.Add(summaryBox, 0, 0);
+ findingsLayout.Controls.Add(warningGrid, 0, 1);
+ split.Panel2.Controls.Add(findingsLayout);
+ // Keep the batch outcome visible; every individual diagnostic lives in the grid.
summaryBox.MaxLength = int.MaxValue;
- // Captured once: when Application.DoEvents ends the outermost message loop, WinForms
- // uninstalls its ambient context, so progress and results must not depend on whichever
- // context is current when planning starts.
uiContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
preview = new PlateView
@@ -118,6 +163,12 @@ public partial class CuttingPlanForm : Form
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
+ // Use the database browser's owner-monitor sizing, without maximizing a modal window.
+ var area = Screen.FromControl(Owner ?? this).WorkingArea;
+ var width = System.Math.Min(area.Width, System.Math.Max(MinimumSize.Width, area.Width * 9 / 10));
+ var height = System.Math.Min(area.Height, System.Math.Max(MinimumSize.Height, area.Height * 9 / 10));
+ Bounds = new Rectangle(area.Left + (area.Width - width) / 2,
+ area.Top + (area.Height - height) / 2, width, height);
// Min sizes and the splitter only fit once the container has its real size.
split.Panel1MinSize = 200;
split.Panel2MinSize = 220;
@@ -242,7 +293,26 @@ public partial class CuttingPlanForm : Form
private void ShowProposal(CuttingPlanProposal result)
{
proposal = result;
- summaryBox.Text = string.Join(Environment.NewLine, result.Describe(unit));
+ summaryBox.Text = string.Join(Environment.NewLine, result.Describe(unit)
+ .Where(line => !line.TrimStart().StartsWith("- ", StringComparison.Ordinal)
+ && !line.TrimStart().StartsWith("... and ", StringComparison.Ordinal)));
+ var warningRows = result.DiagnosticRows();
+ populatingWarnings = true;
+ try
+ {
+ warningGrid.Rows.Clear();
+ foreach (var row in warningRows)
+ {
+ var display = warningGrid.Rows.Add(row.PlateNumber, row.PartNumber, row.OtherPartNumber,
+ row.Message);
+ warningGrid.Rows[display].Tag = row;
+ }
+ warningGrid.ClearSelection();
+ }
+ finally
+ {
+ populatingWarnings = false;
+ }
acceptWarningsCheckBox.Checked = false;
acceptWarningsCheckBox.Visible = result.RequiresWarningAcceptance;
statusLabel.Text = result.CanApply ? "Review the plan, then apply it."
@@ -250,23 +320,89 @@ public partial class CuttingPlanForm : Form
: "Nothing can be applied.";
applyButton.Enabled = result.CanApply;
var index = Array.IndexOf(plates, activePlate);
- var plate = index < 0 ? null : result.BuildPreview(index);
+ ShowPlate(index);
+ }
+
+ private void ShowPlate(int index)
+ {
+ preview.Visible = false;
+ if (proposal == null || index < 0 || index >= proposal.Plates.Count)
+ return;
+ var plate = proposal.BuildPreview(index);
if (plate == null)
{
- if (index >= 0)
- previewLabel.Text = "No preview: this plate's plan is not ready. Part numbers in the summary "
- + "match the editor.";
+ previewLabel.Text = $"Plate {plateNumbers[index]}: no current, usable preview. "
+ + "Part numbers refer to the editor; no location can be certified here.";
return;
}
- previewLabel.Text = result.Plates[index].IsReady ? previewText : "UNVERIFIED — " + previewText;
+ previewLabel.Text = proposal.Plates[index].IsReady
+ ? $"Plate {plateNumbers[index]} — preview, numbered in proposed cutting order:"
+ : $"UNVERIFIED — Plate {plateNumbers[index]} — preview, numbered in proposed cutting order:";
preview.Plate = plate;
preview.Visible = true;
preview.ZoomToFit();
}
+ private void WarningGrid_SelectionChanged(object sender, EventArgs e)
+ {
+ if (populatingWarnings || proposal == null || warningGrid.SelectedRows.Count == 0)
+ return;
+ // SelectionChanged precedes CurrentCell's update; SelectedRows is the new selection.
+ if (warningGrid.SelectedRows[0].Tag is not CuttingPlanDiagnosticRow row)
+ return;
+ ShowPlate(row.PlateIndex);
+ if (!preview.Visible || row.PartNumber is not int partNumber)
+ {
+ if (preview.Visible)
+ previewLabel.Text += " No part location was provided for this warning.";
+ return;
+ }
+ var plan = proposal.Plates[row.PlateIndex].Result;
+ var matched = plan.ProposedOrder.Select((part, position) => (part, position))
+ .FirstOrDefault(item => item.part.SourceOrdinal == partNumber - 1);
+ if (matched.part == null)
+ {
+ previewLabel.Text += " This source part has no proposed preview location.";
+ return;
+ }
+ var bounds = preview.Plate.Parts[matched.position].BoundingBox;
+ if (row.OtherPartNumber is int otherNumber)
+ {
+ var other = plan.ProposedOrder.Select((part, position) => (part, position))
+ .FirstOrDefault(item => item.part.SourceOrdinal == otherNumber - 1);
+ if (other.part != null)
+ {
+ var second = preview.Plate.Parts[other.position].BoundingBox;
+ var left = System.Math.Min(bounds.Left, second.Left);
+ var bottom = System.Math.Min(bounds.Bottom, second.Bottom);
+ bounds = new OpenNest.Geometry.Box(left, bottom,
+ System.Math.Max(bounds.Right, second.Right) - left,
+ System.Math.Max(bounds.Top, second.Top) - bottom);
+ }
+ }
+ if (double.IsFinite(bounds.X) && double.IsFinite(bounds.Y)
+ && double.IsFinite(bounds.Length) && double.IsFinite(bounds.Width)
+ && bounds.Length > 0 && bounds.Width > 0)
+ {
+ var pad = System.Math.Max(0.5, System.Math.Max(bounds.Length, bounds.Width) * 0.12);
+ preview.ZoomToArea(bounds.X - pad, bounds.Y - pad,
+ bounds.Length + 2 * pad, bounds.Width + 2 * pad);
+ }
+ previewLabel.Text += " Showing affected part extent, not an exact warning point.";
+ }
+
private void ClearProposal()
{
proposal = null;
+ populatingWarnings = true;
+ try
+ {
+ warningGrid.Rows.Clear();
+ }
+ finally
+ {
+ populatingWarnings = false;
+ }
acceptWarningsCheckBox.Checked = false;
acceptWarningsCheckBox.Visible = false;
applyButton.Enabled = false;
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index d2aca1c..da1e6fe 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -334,9 +334,19 @@ plans every plate that has parts. Both open one dialog built on
`NoSolutionWithinBudget` is retried once with the current part order, and the summary
says the order was kept. Both are allowed 400 expansions per part (at least the
default 20000), because both still plan contour order and entries for every part.
+- The dialog uses an owner-monitor-sized window (approximately 90% of the working area), like
+ the database browser. The right panel keeps the batch outcome and plate headings above a
+ read-only, unsorted warning grid with Plate, source Part, With, and full finding text. Every
+ overlap issue/pair and every planner finding has its own selectable row, including findings
+ hidden by the compact blocked-plate summary. Selecting a row switches to that plate's
+ detached preview and zooms to the affected part extent (or both parts for a pair), mapping
+ source positions to the proposed cutting order. This is not an exact contact marker: most
+ findings do not carry a geometric witness. If a plate cannot produce a current, supported
+ preview, no geometry is cloned or focused; the row still identifies its source part in the
+ editor. Replanning, cancellation and stale Apply clear old warning targets.
- The summary lists every plate: ready plates with part counts and rapid travel, others
- with their status and findings. Unverified proposals show every overlap and route
- warning in the scrollable summary before the acceptance checkbox is used; they do
+ with their status and findings. Unverified proposals retain every overlap and route
+ warning in the selectable grid before the acceptance checkbox is used; they do
not truncate later parts' warnings. Finding part numbers are the plate's current order, as the
editor numbers them. The preview shows the active plate detached from the nest (quantity
zero, so drawing quantities do not change) in the proposed order with the proposed