feat(cutting-ui): inspect live nest validation in docked panel

This commit is contained in:
aj committed 2026-10-11 21:55:59 -04:00
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.");
}
+244 -26
View File
@@ -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)
+22 -2
View File
@@ -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();
}
+9 -10
View File
@@ -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)
+4 -1
View File
@@ -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
View File
@@ -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