feat(cutting): navigate diagnostic rows in enlarged planner

This commit is contained in:
aj committed 2026-10-10 11:37:57 -04:00
1 parent 69304d3851
commit 17e504fb70
5 files changed
+211 -15

No files matched your search

@@ -236,6 +236,10 @@ public sealed class CuttingPlanPlateResult
Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed);
}
/// <summary>A diagnostic tied to captured plate and source part positions, not display text.</summary>
public sealed record CuttingPlanDiagnosticRow(int PlateIndex, int PlateNumber, int? PartNumber,
int? OtherPartNumber, string Message);
/// <summary>
/// The outcome of a batch. Apply is all-or-nothing. Unverified output needs explicit acceptance.
/// </summary>
@@ -263,6 +267,35 @@ public sealed class CuttingPlanProposal
public bool RequiresWarningAcceptance => CanApplyWithWarnings && !CanApply;
/// <summary>All findings, including ones hidden by a blocked plate's abbreviated summary.</summary>
public IReadOnlyList<CuttingPlanDiagnosticRow> DiagnosticRows()
{
var rows = new List<CuttingPlanDiagnosticRow>();
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;
}
/// <summary>
/// Installs every plate's replayed proposal through <see cref="CuttingPlanService.Apply"/>, on the
/// thread that owns the plates. Only after every plate is applied does each one keep an owned copy
@@ -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));
@@ -59,10 +59,12 @@ public class CuttingPlanFormTests
Assert.False(Control<Button>(form, "applyButton").Enabled);
var summary = Control<TextBox>(form, "summaryBox").Text;
Assert.Contains("Plate 1: blocked", summary);
Assert.Contains("Part 1 (locked)", summary);
var warnings = Control<DataGridView>(form, "warningGrid");
Assert.Contains(warnings.Rows.Cast<DataGridViewRow>(), 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<PlateView>(form, "preview").Visible);
Assert.StartsWith("No preview", Control<Label>(form, "previewLabel").Text);
Assert.Contains("no current, usable preview", Control<Label>(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<TextBox>(form, "summaryBox").Text);
Assert.False(Control<Button>(form, "applyButton").Enabled);
Assert.Empty(Control<DataGridView>(form, "warningGrid").Rows.Cast<DataGridViewRow>());
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<Button>(form, "applyButton").Enabled);
Assert.True(Field<PlateView>(form, "preview").Visible);
Assert.Contains("UNVERIFIED", Control<Label>(form, "previewLabel").Text);
var grid = Control<DataGridView>(form, "warningGrid");
Assert.NotEmpty(grid.Rows.Cast<DataGridViewRow>());
Assert.Contains(grid.Rows.Cast<DataGridViewRow>(), row =>
row.Cells[3].Value?.ToString()?.Contains("Material boundaries") == true);
var partRow = grid.Rows.Cast<DataGridViewRow>().First(row => row.Cells[1].Value is int);
partRow.Selected = true;
Assert.Contains("affected part extent", Control<Label>(form, "previewLabel").Text);
Assert.True(Field<PlateView>(form, "preview").Visible);
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
Assert.Null(form.CommitResult);
Assert.False(part.HasManualLeadIns);
accept.Checked = true;
Assert.True(Control<Button>(form, "applyButton").Enabled);
Invoke(form, "PlanButton_Click", null, EventArgs.Empty);
Assert.Empty(grid.Rows.Cast<DataGridViewRow>()); // No stale warning targets while replanning.
WaitForPlan(form);
Assert.NotEmpty(grid.Rows.Cast<DataGridViewRow>());
Assert.False(accept.Checked);
Assert.False(Control<Button>(form, "applyButton").Enabled);
accept.Checked = true;
+147 -11
View File
@@ -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<bool> 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;
+12 -2
View File
@@ -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