mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-13 14:41:59 -04:00
feat(cutting-ui): inspect live nest validation in docked panel
This commit is contained in:
1 parent
943b63937d
commit
f9b2e1282a
6 files changed
+559
-54
No files matched your search
@@ -0,0 +1,263 @@
|
||||
using System.Drawing;
|
||||
using System.Reflection;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Diagnostics;
|
||||
using OpenNest.Forms;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.WinForms.Tests.Forms;
|
||||
|
||||
[Collection("Fill operation lifetime")]
|
||||
public class CuttingValidationPanelTests
|
||||
{
|
||||
[Fact]
|
||||
public void ValidateMenuUsesSharedPanelAndRealAllPlateFindingsRestoreOriginalView() => RunSta(() =>
|
||||
{
|
||||
using var host = new MenuHost();
|
||||
using var editor = RapidEditor();
|
||||
host.Show();
|
||||
editor.MdiParent = host;
|
||||
editor.Show();
|
||||
editor.Activate();
|
||||
editor.PlateManager.LoadAt(0);
|
||||
var view = editor.PlateView;
|
||||
view.OverlapDisplay = OverlapDisplayMode.Centroids;
|
||||
view.ZoomToArea(-10, -10, 50, 50);
|
||||
var viewport = view.CaptureViewport();
|
||||
var original = view.Plate;
|
||||
var programs = editor.Nest.Plates.SelectMany(p => p.Parts).Select(p => p.Program).ToArray();
|
||||
Invoke(host, "ValidateNest_Click", host, EventArgs.Empty);
|
||||
var panel = Assert.IsType<CuttingInspectionPanel>(editor.CuttingInspection);
|
||||
Pump(() => panel.ValidationReport != null && !panel.IsPlanning);
|
||||
Assert.Equal(CuttingInspectionMode.Validate, panel.Mode);
|
||||
Assert.False(Field<Button>(panel, "applyButton").Visible);
|
||||
Assert.False(Field<Button>(panel, "applyButton").Enabled);
|
||||
Assert.False(view.HasProposalDisplay);
|
||||
var grid = Field<DataGridView>(panel, "warningGrid");
|
||||
Assert.Equal(panel.ValidationReport!.Findings.Count, grid.Rows.Count);
|
||||
var row = grid.Rows.Cast<DataGridViewRow>().Single(r => r.Tag is PostVerificationFinding
|
||||
{ Kind: PostVerificationKind.RapidCrossing, PlateNumber: 2, PartNumber: 3 });
|
||||
grid.ClearSelection();
|
||||
row.Selected = true; // Real SelectionChanged precedes CurrentCell assignment.
|
||||
Assert.Same(editor.Nest.Plates[1], view.Plate);
|
||||
Assert.Equal(new[] { new Vector(0, 2), new Vector(4, 2) }, view.ValidationOverlay.Finding.ContactPoints);
|
||||
Assert.True(view.ValidationOverlay.IsAnimating);
|
||||
var contactPoint = view.PointWorldToControl(new Vector(0, 2));
|
||||
using var bitmap = new Bitmap(view.Width, view.Height);
|
||||
using var graphics = Graphics.FromImage(bitmap);
|
||||
graphics.Clear(Color.Black);
|
||||
var origin = view.CaptureViewport().Origin;
|
||||
graphics.TranslateTransform(origin.X, origin.Y);
|
||||
view.ValidationOverlay.Draw(graphics);
|
||||
var cx = contactPoint.X;
|
||||
var cy = contactPoint.Y;
|
||||
Assert.InRange(cx, 15, bitmap.Width - 16);
|
||||
Assert.InRange(cy, 15, bitmap.Height - 16);
|
||||
Assert.Contains(Enumerable.Range(-2, 5).SelectMany(dx => Enumerable.Range(3, 7)
|
||||
.Select(dy => bitmap.GetPixel(cx + dx, cy + dy))), pixel =>
|
||||
pixel.R > 150 && pixel.B > 50 && pixel.G < 100);
|
||||
Assert.Contains("Crosshairs", Field<TextBox>(panel, "resultLabel").Text);
|
||||
Assert.Contains("Minimum spacing, plate boundaries and final post output are not checked", Field<Label>(panel, "validationScopeLabel").Text);
|
||||
Assert.True(Field<Label>(panel, "validationScopeLabel").Visible);
|
||||
Assert.Contains("Nest validation (live programs)", Field<TextBox>(panel, "resultLabel").Text);
|
||||
panel.RequestClose();
|
||||
Assert.Same(original, view.Plate);
|
||||
Assert.Equal(viewport, view.CaptureViewport());
|
||||
Assert.Equal(OverlapDisplayMode.Centroids, view.OverlapDisplay);
|
||||
Assert.Null(view.ValidationOverlay.Finding);
|
||||
Assert.False(view.InspectionOnly);
|
||||
Assert.Equal(programs, editor.Nest.Plates.SelectMany(p => p.Parts).Select(p => p.Program));
|
||||
});
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void ShownValidationDistinguishesIncompleteFromClear(bool incomplete) => RunSta(() =>
|
||||
{
|
||||
var nest = new Nest();
|
||||
if (incomplete) nest.CreatePlate().Parts.Add(new Part(new Drawing("unsupported", new OpenNest.CNC.Program())));
|
||||
using var editor = new EditNestForm(nest);
|
||||
editor.Show();
|
||||
editor.OpenValidationInspection();
|
||||
var panel = editor.CuttingInspection!;
|
||||
Pump(() => panel.ValidationReport != null && !panel.IsPlanning);
|
||||
Assert.Contains(incomplete ? "Validation incomplete" : "No warnings found", Field<Label>(panel, "previewLabel").Text);
|
||||
Assert.True(Field<Label>(panel, "validationScopeLabel").Visible);
|
||||
Assert.Contains("not posting consent", Field<Label>(panel, "validationScopeLabel").Text);
|
||||
Assert.False(Field<Button>(panel, "applyButton").Enabled);
|
||||
panel.RequestClose();
|
||||
});
|
||||
|
||||
[Theory]
|
||||
[InlineData("PanelDispose")]
|
||||
[InlineData("EditorClose")]
|
||||
[InlineData("HostClose")]
|
||||
public void ExternalCloseJoinsValidationBeforeReleasingMdiGuard(string close) => RunSta(() =>
|
||||
{
|
||||
using var host = new MenuHost();
|
||||
using var editor = RapidEditor();
|
||||
using var other = new EditNestForm(editor.Nest);
|
||||
host.Show();
|
||||
other.MdiParent = host; other.Show();
|
||||
editor.MdiParent = host; editor.Show(); editor.Activate();
|
||||
editor.OpenValidationInspection();
|
||||
var panel = editor.CuttingInspection!;
|
||||
Pump(() => !panel.IsPlanning);
|
||||
var pending = new TaskCompletionSource<PostVerificationReport>();
|
||||
CancellationToken token = default;
|
||||
panel.Analyze = (_, cancellation) => { token = cancellation; return pending.Task; };
|
||||
Field<Button>(panel, "planButton").PerformClick();
|
||||
Assert.True(panel.IsPlanning);
|
||||
Assert.False(other.Enabled);
|
||||
Assert.False(host.AllowDrop);
|
||||
if (close == "PanelDispose") panel.Dispose();
|
||||
else if (close == "EditorClose") editor.Close();
|
||||
else host.Close();
|
||||
Assert.True(token.IsCancellationRequested);
|
||||
Assert.False(panel.IsDisposed);
|
||||
Assert.True(editor.PlateView.InspectionOnly);
|
||||
Assert.False(other.Enabled);
|
||||
pending.SetCanceled();
|
||||
Pump(() => panel.IsDisposed);
|
||||
if (close == "HostClose") Pump(() => host.IsDisposed);
|
||||
else if (close == "EditorClose") Pump(() => editor.IsDisposed);
|
||||
else
|
||||
{
|
||||
Assert.True(other.Enabled);
|
||||
Assert.False(editor.PlateView.InspectionOnly);
|
||||
}
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void ModeSwitchJoinsBothWorkersAndDiscardsOldResultsWithoutUnlocking() => RunSta(() =>
|
||||
{
|
||||
using var editor = RapidEditor();
|
||||
editor.Show();
|
||||
editor.OpenValidationInspection();
|
||||
var panel = editor.CuttingInspection!;
|
||||
Pump(() => !panel.IsPlanning);
|
||||
var pending = new TaskCompletionSource<PostVerificationReport>();
|
||||
CancellationToken token = default;
|
||||
panel.Analyze = (_, cancellation) => { token = cancellation; return pending.Task; };
|
||||
Field<Button>(panel, "planButton").PerformClick();
|
||||
using var entered = new ManualResetEventSlim();
|
||||
using var release = new ManualResetEventSlim();
|
||||
panel.BeforePlan = cancellation => { entered.Set(); release.Wait(TimeSpan.FromSeconds(20)); cancellation.ThrowIfCancellationRequested(); };
|
||||
try
|
||||
{
|
||||
Field<ComboBox>(panel, "modeSelector").SelectedIndex = 0;
|
||||
Assert.True(token.IsCancellationRequested);
|
||||
Assert.Equal(CuttingInspectionMode.Validate, panel.Mode);
|
||||
Assert.True(editor.PlateView.InspectionOnly);
|
||||
pending.SetResult(PostVerificationAnalyzer.Analyze(new Nest()));
|
||||
Pump(() => entered.IsSet);
|
||||
Assert.Equal(CuttingInspectionMode.Plan, panel.Mode);
|
||||
Assert.Null(panel.ValidationReport);
|
||||
Assert.True(editor.PlateView.InspectionOnly);
|
||||
panel.Analyze = (nest, cancellation) => Task.FromResult(PostVerificationAnalyzer.Analyze(nest, cancellation));
|
||||
Field<ComboBox>(panel, "modeSelector").SelectedIndex = 1;
|
||||
Assert.Equal(CuttingInspectionMode.Plan, panel.Mode);
|
||||
Assert.True(editor.PlateView.InspectionOnly);
|
||||
release.Set();
|
||||
Pump(() => panel.Mode == CuttingInspectionMode.Validate && !panel.IsPlanning);
|
||||
Assert.NotNull(panel.ValidationReport);
|
||||
Assert.Null(panel.Proposal);
|
||||
Assert.False(editor.PlateView.HasProposalDisplay);
|
||||
Assert.False(Field<CheckBox>(panel, "acceptWarningsCheckBox").Checked);
|
||||
panel.RequestClose();
|
||||
}
|
||||
finally { release.Set(); }
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void RecheckFailureNeverRetainsOldClearOrOverlay() => RunSta(() =>
|
||||
{
|
||||
using var editor = RapidEditor();
|
||||
editor.Show();
|
||||
editor.OpenValidationInspection();
|
||||
var panel = editor.CuttingInspection!;
|
||||
Pump(() => !panel.IsPlanning);
|
||||
panel.Analyze = (_, _) => throw new InvalidOperationException("synthetic validation failure");
|
||||
Field<Button>(panel, "planButton").PerformClick();
|
||||
Pump(() => !panel.IsPlanning);
|
||||
Assert.Null(panel.ValidationReport);
|
||||
Assert.Null(editor.PlateView.ValidationOverlay.Finding);
|
||||
Assert.Empty(Field<DataGridView>(panel, "warningGrid").Rows.Cast<DataGridViewRow>());
|
||||
Assert.Contains("Validation failed", Field<TextBox>(panel, "resultLabel").Text);
|
||||
Assert.DoesNotContain("No warnings found", Field<Label>(panel, "previewLabel").Text);
|
||||
panel.RequestClose();
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void RestoreObserverFailureStillReleasesPanelAndInspectionLock() => RunSta(() =>
|
||||
{
|
||||
using var editor = RapidEditor();
|
||||
editor.Show();
|
||||
editor.PlateManager.LoadAt(0);
|
||||
editor.OpenValidationInspection();
|
||||
var panel = editor.CuttingInspection!;
|
||||
Pump(() => panel.ValidationReport != null && !panel.IsPlanning);
|
||||
var row = Field<DataGridView>(panel, "warningGrid").Rows.Cast<DataGridViewRow>()
|
||||
.Single(r => r.Tag is PostVerificationFinding
|
||||
{ Kind: PostVerificationKind.RapidCrossing, PlateNumber: 2, PartNumber: 3 });
|
||||
row.Selected = true;
|
||||
Assert.Equal(1, editor.PlateManager.CurrentIndex);
|
||||
Exception? reported = null;
|
||||
panel.OnRestoreError = ex => reported = ex;
|
||||
void ThrowOnRestore(object? _, PlateChangedEventArgs e)
|
||||
{
|
||||
if (e.Index == 0)
|
||||
throw new InvalidOperationException("synthetic plate-change observer");
|
||||
}
|
||||
editor.PlateManager.CurrentPlateChanged += ThrowOnRestore;
|
||||
try
|
||||
{
|
||||
panel.RequestClose();
|
||||
Assert.IsType<InvalidOperationException>(reported);
|
||||
Assert.Null(editor.CuttingInspection);
|
||||
Assert.True(panel.IsDisposed);
|
||||
Assert.False(editor.PlateView.InspectionOnly);
|
||||
}
|
||||
finally
|
||||
{
|
||||
editor.PlateManager.CurrentPlateChanged -= ThrowOnRestore;
|
||||
}
|
||||
});
|
||||
|
||||
private static EditNestForm RapidEditor()
|
||||
{
|
||||
var nest = new Nest();
|
||||
nest.CreatePlate().Size = new OpenNest.Geometry.Size(100, 100);
|
||||
var plate = nest.CreatePlate();
|
||||
plate.Size = new OpenNest.Geometry.Size(100, 100);
|
||||
var program = new OpenNest.CNC.Program();
|
||||
program.MoveTo(0, 0); program.LineTo(4, 0); program.LineTo(4, 4); program.LineTo(0, 4); program.LineTo(0, 0);
|
||||
plate.Parts.Add(new Part(new Drawing("square", program)));
|
||||
foreach (var x in new[] { -2d, 6d })
|
||||
{
|
||||
var scribe = new OpenNest.CNC.Program();
|
||||
scribe.MoveTo(x, 2);
|
||||
scribe.Codes.Add(new LinearMove(x, 2) { Layer = LayerType.Scribe });
|
||||
plate.Parts.Add(new Part(new Drawing("scribe", scribe)));
|
||||
}
|
||||
return new EditNestForm(nest);
|
||||
}
|
||||
|
||||
private sealed class MenuHost : MainForm
|
||||
{
|
||||
protected override void OnLoad(EventArgs e) { }
|
||||
}
|
||||
|
||||
private static T Field<T>(object target, string name) => Assert.IsType<T>(target.GetType()
|
||||
.GetField(name, BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(target));
|
||||
private static void Invoke(Control target, string name, params object[] args) => typeof(MainForm)
|
||||
.GetMethod(name, BindingFlags.NonPublic | BindingFlags.Instance)!.Invoke(target, args);
|
||||
private static void Pump(Func<bool> condition)
|
||||
{
|
||||
var until = DateTime.UtcNow.AddSeconds(30);
|
||||
while (!condition() && DateTime.UtcNow < until) { Application.DoEvents(); Thread.Sleep(10); }
|
||||
Assert.True(condition(), "Timed out waiting for validation panel.");
|
||||
}
|
||||
private static void RunSta(System.Action action) => StaTestThread.Run(action, TimeSpan.FromSeconds(45), "Validation panel STA timed out.");
|
||||
}
|
||||
@@ -13,6 +13,8 @@ using OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
namespace OpenNest.Forms;
|
||||
|
||||
internal enum CuttingInspectionMode { Plan, Validate }
|
||||
|
||||
/// <summary>Owner-thread planning and atomic Apply beside the read-only main canvas.</summary>
|
||||
internal sealed class CuttingInspectionPanel : UserControl
|
||||
{
|
||||
@@ -29,7 +31,16 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
private readonly SynchronizationContext context;
|
||||
private readonly (float Scale, PointF Origin) viewport;
|
||||
private readonly OverlapDisplayMode overlapDisplay;
|
||||
private readonly CuttingProposalInspection inspection = new();
|
||||
private CuttingProposalInspection inspection = new();
|
||||
private readonly ComboBox modeSelector = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 220 };
|
||||
private CancellationTokenSource validationCancellation;
|
||||
private bool closeRequested;
|
||||
private CuttingInspectionMode? pendingMode;
|
||||
private bool configuringMode;
|
||||
internal CuttingInspectionMode Mode { get; private set; }
|
||||
internal PostVerificationReport ValidationReport { get; private set; }
|
||||
internal Func<Nest, CancellationToken, Task<PostVerificationReport>> Analyze { get; set; }
|
||||
= (nest, token) => Task.Run(() => PostVerificationAnalyzer.Analyze(nest, token), token);
|
||||
private readonly Button planButton = new() { Text = "Replan", AutoSize = true };
|
||||
private readonly Button cancelButton = new() { Text = "Cancel / Close", AutoSize = true };
|
||||
private readonly TextBox resultLabel = new()
|
||||
@@ -50,6 +61,13 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
private readonly CheckBox acceptWarningsCheckBox = new() { Text = "Accept UNVERIFIED plan warnings (not posting consent)", AutoSize = true, Visible = false };
|
||||
private readonly Label settingsLabel = new() { AutoSize = true, MaximumSize = new Size(440, 0) };
|
||||
private readonly Label previewLabel = new() { AutoSize = true, MaximumSize = new Size(440, 0) };
|
||||
private readonly Label validationScopeLabel = new()
|
||||
{
|
||||
AutoSize = true,
|
||||
MaximumSize = new Size(440, 0),
|
||||
Visible = false,
|
||||
Text = "LIVE NEST only — not posting consent. Minimum spacing, plate boundaries and final post output are not checked."
|
||||
};
|
||||
private readonly DataGridView warningGrid = new()
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
@@ -65,7 +83,8 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
private bool closed;
|
||||
private int generation;
|
||||
|
||||
internal CuttingInspectionPanel(EditNestForm editor, CuttingParameters parameters, bool allPlates = false)
|
||||
internal CuttingInspectionPanel(EditNestForm editor, CuttingParameters parameters, bool allPlates = false,
|
||||
CuttingInspectionMode mode = CuttingInspectionMode.Plan)
|
||||
{
|
||||
this.editor = editor;
|
||||
view = editor.PlateView;
|
||||
@@ -85,7 +104,14 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
buttons.Controls.AddRange([settingsButton, planButton, applyButton, cancelButton]);
|
||||
var navigation = new FlowLayoutPanel { AutoSize = true };
|
||||
navigation.Controls.AddRange([previousButton, nextButton]);
|
||||
header.Controls.AddRange([settingsLabel, keepOrderCheckBox, buttons, acceptWarningsCheckBox, navigation, previewLabel]);
|
||||
modeSelector.Items.AddRange(["Plan Cutting", "Validate Nest (live programs)"]);
|
||||
Mode = mode;
|
||||
modeSelector.SelectedIndex = (int)mode;
|
||||
modeSelector.SelectedIndexChanged += (_, _) =>
|
||||
{
|
||||
if (!configuringMode) SwitchMode((CuttingInspectionMode)modeSelector.SelectedIndex);
|
||||
};
|
||||
header.Controls.AddRange([modeSelector, settingsLabel, keepOrderCheckBox, buttons, acceptWarningsCheckBox, navigation, previewLabel, validationScopeLabel]);
|
||||
var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 3 };
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 30));
|
||||
@@ -103,7 +129,7 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
AutoSizeMode = name == "Warning / finding" ? DataGridViewAutoSizeColumnMode.Fill : DataGridViewAutoSizeColumnMode.None
|
||||
});
|
||||
Padding = new Padding(10);
|
||||
planButton.Click += (_, _) => StartPlanning();
|
||||
planButton.Click += (_, _) => StartCurrentMode();
|
||||
cancelButton.Click += (_, _) => RequestClose();
|
||||
settingsButton.Click += (_, _) => EditSettings();
|
||||
keepOrderCheckBox.CheckedChanged += (_, _) => StartPlanning();
|
||||
@@ -116,7 +142,7 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
resultLabel.Text = "Read-only proposal inspection. Nothing live changes before Apply.";
|
||||
}
|
||||
|
||||
internal bool IsPlanning => inspection.IsRunning;
|
||||
internal bool IsPlanning => inspection.IsRunning || validationCancellation != null;
|
||||
internal Task PlanningTask { get; private set; } = Task.CompletedTask;
|
||||
internal Action<CancellationToken> BeforePlan { get; set; }
|
||||
internal Action BeforePresent { get; set; }
|
||||
@@ -130,12 +156,167 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
view.InspectionCloseRequested = RequestClose;
|
||||
view.OverlapDisplay = OverlapDisplayMode.Off;
|
||||
view.ClearValidationFinding();
|
||||
StartPlanning();
|
||||
ConfigureMode();
|
||||
StartCurrentMode();
|
||||
}
|
||||
|
||||
private void StartCurrentMode()
|
||||
{
|
||||
if (Mode == CuttingInspectionMode.Validate) StartValidation();
|
||||
else StartPlanning();
|
||||
}
|
||||
|
||||
private void ConfigureMode()
|
||||
{
|
||||
configuringMode = true;
|
||||
modeSelector.SelectedIndex = (int)Mode;
|
||||
configuringMode = false;
|
||||
var planning = Mode == CuttingInspectionMode.Plan;
|
||||
settingsButton.Visible = settingsLabel.Visible = keepOrderCheckBox.Visible = applyButton.Visible = planning;
|
||||
previousButton.Visible = nextButton.Visible = planning;
|
||||
validationScopeLabel.Visible = !planning;
|
||||
planButton.Text = planning ? "Replan" : "Recheck all plates";
|
||||
previewLabel.Text = planning ? "No current preview." : "LIVE NEST — read-only validation of all plates.";
|
||||
}
|
||||
|
||||
internal void SwitchMode(CuttingInspectionMode mode)
|
||||
{
|
||||
if (closed || closeRequested || mode == Mode && pendingMode == null) return;
|
||||
pendingMode = mode;
|
||||
ClearResult();
|
||||
SetPlanning(true);
|
||||
resultLabel.Text = "Switching modes… waiting for the worker.";
|
||||
if (validationCancellation != null) validationCancellation.Cancel();
|
||||
if (inspection.IsRunning) inspection.RequestClose();
|
||||
if (!IsPlanning) FinishTransition();
|
||||
}
|
||||
|
||||
private void FinishTransition()
|
||||
{
|
||||
if (closeRequested) { FinishClose(); return; }
|
||||
if (pendingMode is not { } mode) return;
|
||||
pendingMode = null;
|
||||
RestoreLiveView();
|
||||
inspection = new CuttingProposalInspection();
|
||||
Mode = mode;
|
||||
ConfigureMode();
|
||||
StartCurrentMode();
|
||||
}
|
||||
|
||||
private void RestoreLiveView()
|
||||
{
|
||||
view.ClearProposalDisplay();
|
||||
view.ClearValidationFinding();
|
||||
if (view.IsDisposed) return;
|
||||
var index = editor.Nest.Plates.ToList().IndexOf(plate);
|
||||
if (index >= 0 && !ReferenceEquals(view.Plate, plate)) editor.PlateManager.LoadAt(index);
|
||||
view.RestoreViewport(viewport);
|
||||
}
|
||||
|
||||
private void StartValidation()
|
||||
{
|
||||
if (closed || closeRequested || IsPlanning) return;
|
||||
ClearResult();
|
||||
ConfigureMode();
|
||||
SetPlanning(true);
|
||||
resultLabel.Text = "Checking all live plates: overlaps, missing lead-ins and rapid crossings…";
|
||||
var cancellation = validationCancellation = new CancellationTokenSource();
|
||||
var run = ++generation;
|
||||
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
PlanningTask = completion.Task;
|
||||
// The inspection scope keeps editors and posting from mutating these live inputs.
|
||||
Task<PostVerificationReport> work;
|
||||
try { work = Analyze(editor.Nest, cancellation.Token); }
|
||||
catch (Exception ex) { work = Task.FromException<PostVerificationReport>(ex); }
|
||||
work.ContinueWith(task => context.Post(_ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
validationCancellation = null;
|
||||
cancellation.Dispose();
|
||||
if (closeRequested || pendingMode != null) { FinishTransition(); return; }
|
||||
if (closed || run != generation) return;
|
||||
SetPlanning(false);
|
||||
if (!task.IsCompletedSuccessfully)
|
||||
{
|
||||
resultLabel.Text = task.IsCanceled ? "Validation cancelled. This nest has not passed the checks."
|
||||
: $"Validation failed. This nest has not passed the checks. {task.Exception?.GetBaseException().Message}";
|
||||
return;
|
||||
}
|
||||
ValidationReport = task.Result;
|
||||
resultLabel.Text = ValidationReport.ToDisplayText("Nest validation (live programs)");
|
||||
previewLabel.Text = ValidationReport.Findings.Any(f => f.Kind == PostVerificationKind.Incomplete)
|
||||
? "Validation incomplete. Review the findings; this nest has not passed all checks."
|
||||
: ValidationReport.HasWarnings ? "Warnings found. Review the findings below."
|
||||
: "Validation complete. No warnings found by these checks.";
|
||||
populatingWarnings = true;
|
||||
try
|
||||
{
|
||||
foreach (var finding in ValidationReport.Findings)
|
||||
{
|
||||
var index = warningGrid.Rows.Add(finding.PlateNumber, finding.PartNumber, finding.OtherPartNumber,
|
||||
$"{finding.Kind}: {finding.Message}");
|
||||
warningGrid.Rows[index].Tag = finding;
|
||||
}
|
||||
warningGrid.ClearSelection();
|
||||
}
|
||||
finally { populatingWarnings = false; }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
view.ClearValidationFinding();
|
||||
resultLabel.Text = $"Validation failed. This nest has not passed the checks. {ex.Message}";
|
||||
}
|
||||
finally { completion.TrySetResult(); }
|
||||
}, null), CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
private void NavigateValidationFinding()
|
||||
{
|
||||
if (closed || closeRequested || pendingMode != null || populatingWarnings || IsPlanning || editor.IsDisposed)
|
||||
return;
|
||||
view.ClearValidationFinding();
|
||||
if (warningGrid.SelectedRows.Count == 0 || warningGrid.SelectedRows[0].Tag is not PostVerificationFinding finding
|
||||
|| finding.PlateNumber < 1 || finding.PlateNumber > editor.Nest.Plates.Count) return;
|
||||
if (editor.PlateManager.CurrentIndex != finding.PlateNumber - 1)
|
||||
editor.PlateManager.LoadAt(finding.PlateNumber - 1);
|
||||
var location = finding.Location;
|
||||
if (location == null && finding.PartNumber is { } part && part > 0 && part <= view.Plate.Parts.Count)
|
||||
location = view.Plate.Parts[part - 1]?.BoundingBox?.Center;
|
||||
if (location is { } point && double.IsFinite(point.X) && double.IsFinite(point.Y))
|
||||
{
|
||||
var extent = finding.Overlap?.Bounds;
|
||||
var size = System.Math.Max(10, System.Math.Max(extent?.Length ?? 0, extent?.Width ?? 0) * 2);
|
||||
if (finding.RapidStart is { } start && finding.RapidEnd is { } end)
|
||||
size = System.Math.Max(size, start.DistanceTo(end) * 1.5);
|
||||
view.ZoomToArea(point.X - size / 2, point.Y - size / 2, size, size);
|
||||
// A narrow docked canvas can put a contact at its edge after zoom.
|
||||
// Centre a typed witness, never infer one from the diagnostic text.
|
||||
var focus = finding.Kind == PostVerificationKind.RapidCrossing
|
||||
&& finding.ContactPoints.Any(contact => double.IsFinite(contact.X) && double.IsFinite(contact.Y))
|
||||
? finding.ContactPoints.First(contact => double.IsFinite(contact.X) && double.IsFinite(contact.Y))
|
||||
: point;
|
||||
view.CenterValidationPoint(focus, new Point(view.Width / 2, view.Height / 2));
|
||||
}
|
||||
else view.ZoomToFit();
|
||||
view.ShowValidationFinding(finding with { Location = location });
|
||||
var hint = finding.Kind switch
|
||||
{
|
||||
PostVerificationKind.Overlap => "Shaded material is the overlap. The ring marks its area centroid, which can lie between separate patches.",
|
||||
PostVerificationKind.RapidCrossing => finding.ContactPoints.Count > 0
|
||||
? "Crosshairs mark contacts with the completed contour. The pulsing dashed orange line shows the rapid."
|
||||
: "The pulsing dashed orange line shows the rapid inside the completed contour; there is no boundary contact point to mark.",
|
||||
PostVerificationKind.MissingLeadIn => "The ring marks the cutting contour start where a lead-in is missing.",
|
||||
_ => "The ring marks the affected part's center when available; no exact problem location is known."
|
||||
};
|
||||
// Keep the complete report visible; selecting a finding does not hide completeness or other warnings.
|
||||
resultLabel.Text = $"Plate {finding.PlateNumber}, part {finding.PartNumber}: {finding.Message}\r\n{hint}\r\n\r\n"
|
||||
+ ValidationReport.ToDisplayText("Nest validation (live programs)");
|
||||
}
|
||||
|
||||
internal void StartPlanning()
|
||||
{
|
||||
if (closed || inspection.IsRunning || inspection.CloseRequested)
|
||||
if (Mode != CuttingInspectionMode.Plan || closed || IsPlanning || closeRequested || inspection.CloseRequested)
|
||||
return;
|
||||
ClearResult();
|
||||
SetPlanning(true);
|
||||
@@ -168,9 +349,9 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
try
|
||||
{
|
||||
inspection.CompletePlanning(work);
|
||||
if (inspection.CloseRequested)
|
||||
if (closeRequested || pendingMode != null)
|
||||
{
|
||||
FinishClose();
|
||||
FinishTransition();
|
||||
return;
|
||||
}
|
||||
if (closed || run != generation)
|
||||
@@ -226,6 +407,7 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
|
||||
private void ClearResult()
|
||||
{
|
||||
ValidationReport = null;
|
||||
invalidated = true;
|
||||
preview = null;
|
||||
view.ClearProposalDisplay();
|
||||
@@ -248,7 +430,7 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
if (running) applyButton.Enabled = false;
|
||||
}
|
||||
|
||||
private void UpdateApply() => applyButton.Enabled = !invalidated && !IsPlanning
|
||||
private void UpdateApply() => applyButton.Enabled = Mode == CuttingInspectionMode.Plan && !invalidated && !IsPlanning
|
||||
&& (Proposal?.CanApply == true || Proposal?.CanApplyWithWarnings == true && acceptWarningsCheckBox.Checked);
|
||||
|
||||
private bool IsPreviewCurrent()
|
||||
@@ -286,6 +468,11 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
|
||||
private void NavigateFinding()
|
||||
{
|
||||
if (Mode == CuttingInspectionMode.Validate)
|
||||
{
|
||||
NavigateValidationFinding();
|
||||
return;
|
||||
}
|
||||
if (populatingWarnings || invalidated || warningGrid.SelectedRows.Count == 0
|
||||
|| warningGrid.SelectedRows[0].Tag is not CuttingPlanDiagnosticRow row)
|
||||
return;
|
||||
@@ -381,36 +568,67 @@ internal sealed class CuttingInspectionPanel : UserControl
|
||||
{
|
||||
if (closed)
|
||||
return;
|
||||
closeRequested = true;
|
||||
pendingMode = null;
|
||||
ClearResult();
|
||||
SetPlanning(true);
|
||||
cancelButton.Enabled = false;
|
||||
resultLabel.Text = "Cancelling… waiting for the worker. Nothing has changed.";
|
||||
if (inspection.RequestClose())
|
||||
validationCancellation?.Cancel();
|
||||
inspection.RequestClose();
|
||||
if (!IsPlanning)
|
||||
FinishClose();
|
||||
}
|
||||
|
||||
internal Action<Exception> OnRestoreError { get; set; }
|
||||
|
||||
private void FinishClose()
|
||||
{
|
||||
if (closed)
|
||||
return;
|
||||
closed = true;
|
||||
view.ClearProposalDisplay();
|
||||
if (!view.IsDisposed)
|
||||
{
|
||||
view.InspectionCloseRequested = null;
|
||||
if (ReferenceEquals(view.Plate, plate))
|
||||
{
|
||||
view.OverlapDisplay = overlapDisplay;
|
||||
}
|
||||
}
|
||||
Exception restoreError = null;
|
||||
var ownedScope = scope;
|
||||
ownedScope?.Dispose();
|
||||
scope = null;
|
||||
editor.FinishCuttingInspection(this);
|
||||
if (!view.IsDisposed && ReferenceEquals(view.Plate, plate))
|
||||
view.RestoreViewport(viewport);
|
||||
ownedScope?.ResumeHostClose();
|
||||
Dispose();
|
||||
try
|
||||
{
|
||||
RestoreLiveView();
|
||||
if (!view.IsDisposed && ReferenceEquals(view.Plate, plate))
|
||||
view.OverlapDisplay = overlapDisplay;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
restoreError = ex;
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Navigation can invoke arbitrary plate-change observers. Even if one throws,
|
||||
// release the scope and panel so a stopped worker never strands editing.
|
||||
if (!view.IsDisposed)
|
||||
view.InspectionCloseRequested = null;
|
||||
try { ownedScope?.Dispose(); }
|
||||
catch (Exception ex) { restoreError ??= ex; }
|
||||
try { editor.FinishCuttingInspection(this); }
|
||||
catch (Exception ex) { restoreError ??= ex; }
|
||||
try
|
||||
{
|
||||
if (!view.IsDisposed && ReferenceEquals(view.Plate, plate))
|
||||
view.RestoreViewport(viewport);
|
||||
}
|
||||
catch (Exception ex) { restoreError ??= ex; }
|
||||
try { ownedScope?.ResumeHostClose(); }
|
||||
catch (Exception ex) { restoreError ??= ex; }
|
||||
Dispose();
|
||||
}
|
||||
if (restoreError != null)
|
||||
{
|
||||
if (OnRestoreError != null)
|
||||
OnRestoreError(restoreError);
|
||||
else
|
||||
MessageBox.Show(editor.IsDisposed ? null : editor,
|
||||
"Inspection closed, but the original view could not be fully restored: " + restoreError.Message,
|
||||
"Cutting Inspection", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
|
||||
@@ -278,17 +278,37 @@ namespace OpenNest.Forms
|
||||
}
|
||||
internal System.Action<System.Threading.CancellationToken> CuttingBeforePlan { get; set; }
|
||||
|
||||
internal void OpenValidationInspection()
|
||||
{
|
||||
if (CuttingInspection != null)
|
||||
{
|
||||
CuttingInspection.SwitchMode(CuttingInspectionMode.Validate);
|
||||
return;
|
||||
}
|
||||
OpenInspection(true, CuttingInspectionMode.Validate);
|
||||
}
|
||||
|
||||
internal void OpenCuttingInspection(bool allPlates = false)
|
||||
{
|
||||
if (CuttingInspection != null)
|
||||
{
|
||||
CuttingInspection.SwitchMode(CuttingInspectionMode.Plan);
|
||||
return;
|
||||
}
|
||||
OpenInspection(allPlates, CuttingInspectionMode.Plan);
|
||||
}
|
||||
|
||||
private void OpenInspection(bool allPlates, CuttingInspectionMode mode)
|
||||
{
|
||||
if (CuttingInspection != null)
|
||||
return;
|
||||
PlateView.EndAction();
|
||||
var panel = new CuttingInspectionPanel(this, LoadOrDefaultParameters(PlateView.Plate.CuttingParameters), allPlates)
|
||||
var panel = new CuttingInspectionPanel(this, LoadOrDefaultParameters(PlateView.Plate.CuttingParameters), allPlates, mode)
|
||||
{
|
||||
BeforePlan = CuttingBeforePlan
|
||||
};
|
||||
CuttingInspection = panel;
|
||||
ShowSidePanel(panel, allPlates ? "Plan Cutting — All Plates" : "Plan Cutting — Read-only Proposal", 480);
|
||||
ShowSidePanel(panel, "Cutting Inspection — Plan / Validate", 480);
|
||||
panel.Open();
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ namespace OpenNest.Forms
|
||||
public partial class MainForm : Form
|
||||
{
|
||||
private EditNestForm activeForm;
|
||||
private ValidateNestForm validationForm;
|
||||
|
||||
private bool clickUpdateLocation;
|
||||
private bool nestingInProgress;
|
||||
private CancellationTokenSource nestingCts;
|
||||
@@ -1629,13 +1629,7 @@ namespace OpenNest.Forms
|
||||
|
||||
foreach (var view in views)
|
||||
view.SetAction(typeof(ActionSelect));
|
||||
if (validationForm is { IsDisposed: false })
|
||||
{
|
||||
validationForm.Activate();
|
||||
return;
|
||||
}
|
||||
validationForm = new ValidateNestForm(editForm);
|
||||
validationForm.Show(this);
|
||||
editForm.OpenValidationInspection();
|
||||
}
|
||||
|
||||
private void PostProcessor_Click(object sender, EventArgs e)
|
||||
@@ -1649,6 +1643,12 @@ namespace OpenNest.Forms
|
||||
|
||||
// Closed progress windows can still have a fill awaiting its final commit.
|
||||
// Keep the nest stable throughout verification and all owned modal dialogs.
|
||||
if (MdiChildren.OfType<EditNestForm>().Any(form => form.CuttingInspection != null))
|
||||
{
|
||||
MessageBox.Show(this, "Close cutting inspection before posting. Posting will run fresh checks.",
|
||||
"Verify Nest Before Posting", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
var views = MdiChildren.OfType<EditNestForm>()
|
||||
.Where(form => ReferenceEquals(form.Nest, editForm.Nest))
|
||||
.Select(form => form.PlateView).ToArray();
|
||||
@@ -1927,8 +1927,7 @@ namespace OpenNest.Forms
|
||||
|
||||
private void ShowCuttingPlan(bool allPlates)
|
||||
{
|
||||
if (validationForm is { IsDisposed: false }
|
||||
|| MdiChildren.OfType<EditNestForm>().Any(form => form.CuttingInspection != null))
|
||||
if (MdiChildren.OfType<EditNestForm>().Any(form => form.CuttingInspection != null && !ReferenceEquals(form, activeForm)))
|
||||
return;
|
||||
var editForm = activeForm;
|
||||
if (editForm?.PlateView?.Plate == null)
|
||||
|
||||
@@ -357,7 +357,10 @@ for UNVERIFIED results; a blocked plate disables the whole-batch Apply. A succes
|
||||
Apply persists the confirmed settings and refreshes the editor; a failed/stale Apply
|
||||
changes nothing and requires replanning. Escape, the panel X or Cancel restores the
|
||||
live view and prior viewport; closing during planning waits for the worker before
|
||||
unlocking inspection. Planning is not live-nest validation or posting consent.
|
||||
unlocking inspection. Planning is not live-nest validation or posting consent. The
|
||||
panel's **Validate Nest (live programs)** mode separately checks current installed
|
||||
programs across all plates and navigates typed findings on the live canvas; it
|
||||
never certifies an unapplied proposal. Posting still initiates its own fresh review.
|
||||
|
||||
- The panel starts from the plate's cutting settings (or the last-used settings) and plans
|
||||
at once. `Cutting Settings...` edits them and `Keep the current part order` fixes the
|
||||
|
||||
+17
-15
@@ -1,15 +1,16 @@
|
||||
# Verify a nest before posting
|
||||
|
||||
## Standalone desktop validation
|
||||
## Docked desktop validation
|
||||
|
||||
Choose **Nest > Validate Nest...** to inspect all plates without running the cut
|
||||
planner or selecting a post processor. The read-only **Validate Nest** window
|
||||
checks overlapping material, missing lead-ins, and rapid crossings in the existing
|
||||
placed programs and cutting order. Findings identify plate and part sequence
|
||||
numbers in a read-only grid like the cut planner; incomplete or failed checks are
|
||||
never reported as clear. Select a row to switch the main plate view to that plate
|
||||
and focus the finding. The inspector opens at the right of the screen and can be
|
||||
moved or resized. The details below the grid explain the selected warning.
|
||||
planner or selecting a post processor. It opens the editor's read-only side panel
|
||||
in **Validate Nest (live programs)** mode, distinct from the panel's unapplied
|
||||
**Plan Cutting** proposal. The validation panel checks overlapping material,
|
||||
missing lead-ins, and rapid crossings in the existing placed programs and cutting
|
||||
order. Findings identify plate and part sequence numbers in a read-only grid;
|
||||
incomplete or failed checks are never reported as clear. Select a row to switch
|
||||
the main plate view to that plate and focus the finding. The scrollable report
|
||||
retains every result and the selected finding's explanation.
|
||||
While a finding is selected, its named parts retain their normal colors; other
|
||||
parts use muted gray fills and visible gray outlines, lead-ins, and marks. Selecting
|
||||
another finding updates the emphasis. Clearing the finding or closing validation
|
||||
@@ -33,14 +34,15 @@ the nest, or write CNC output. Minimum spacing and plate boundaries are not chec
|
||||
It does not verify a post processor's final output. Posting still runs its own
|
||||
fresh verification and consent flow.
|
||||
|
||||
Finish or cancel active nesting/fill operations before validating. The window is
|
||||
a normal top-level, modeless form. Editing controls and shortcuts stay disabled
|
||||
while it is open, but the main plate view remains available for wheel zoom,
|
||||
middle-button pan, and **F** or middle-button double-click to fit. Close the
|
||||
inspector to resume editing. Cancel, Escape, or closing the window requests
|
||||
Finish or cancel active nesting/fill operations before validating. The panel is
|
||||
exclusive with editing and other side-panel actions while it owns the nest. The
|
||||
main plate view remains available for wheel zoom, middle-button pan, and **F**
|
||||
or middle-button double-click to fit. Panel X, Cancel or Escape requests
|
||||
cancellation and waits for the worker to stop before restoring editing controls.
|
||||
Closing the main window during inspection closes/cancels the inspector first;
|
||||
close the main window again after inspection has finished.
|
||||
Switching to Plan mode also waits for any validation worker; it does not promote
|
||||
a live validation report into a plan check. Closing the editor or main window
|
||||
joins the worker before continuing the close. Posting while inspection is open
|
||||
is refused; close the panel and use the fresh posting review.
|
||||
|
||||
## Posting workflow
|
||||
|
||||
|
||||
Reference in new issue
Block a user