mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-11 17:54:40 -04:00
feat(cutting): navigate diagnostic rows in enlarged planner
This commit is contained in:
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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user