refactor(cutting-ui): retire redundant planning and validation forms

This commit is contained in:
aj committed 2026-10-11 22:15:35 -04:00
1 parent f9b2e1282a
commit d6ae706098
17 files changed
+268 -1829

No files matched your search

@@ -0,0 +1,47 @@
using System.Drawing;
using OpenNest.Controls;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests.Controls;
public class ValidationOverlayTests
{
[Fact]
public void RapidContactCrosshairIsDrawnAtContactAndClearedWithFinding() => RunSta(() =>
{
using var view = new PlateView();
view.ShowValidationFinding(new PostVerificationFinding(PostVerificationKind.RapidCrossing, 1, 2, 1, "crossing")
{
RapidStart = new Vector(-1, 0),
RapidEnd = new Vector(1, 0),
ContactPoints = Array.AsReadOnly(new[] { Vector.Zero })
});
using var bitmap = new Bitmap(120, 120);
using var graphics = Graphics.FromImage(bitmap);
graphics.TranslateTransform(60, 60);
graphics.Clear(Color.Black);
view.ValidationOverlay.Draw(graphics);
var pixel = bitmap.GetPixel(60, 65);
Assert.True(pixel.R > 150 && pixel.B > 50 && pixel.G < 100);
view.ClearValidationFinding();
graphics.Clear(Color.Black);
view.ValidationOverlay.Draw(graphics);
Assert.Equal(Color.Black.ToArgb(), bitmap.GetPixel(60, 65).ToArgb());
});
[Fact]
public void PlateSwitchClearsAnimation() => RunSta(() =>
{
using var view = new PlateView();
view.ShowValidationFinding(new PostVerificationFinding(PostVerificationKind.MissingLeadIn, 1, 1, null, "missing")
{ Location = new Vector(3, 4) });
Assert.True(view.ValidationOverlay.IsAnimating);
view.Plate = new Plate();
Assert.False(view.ValidationOverlay.IsAnimating);
Assert.Null(view.ValidationOverlay.Finding);
});
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(30), "Validation overlay test timed out.");
}
@@ -220,7 +220,20 @@ public class CuttingInspectionPanelTests
Assert.False(accept.Checked);
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.False(Field<Button>(panel, "applyButton").Enabled);
panel.RequestClose();
var grid = Field<DataGridView>(panel, "warningGrid");
Assert.NotEmpty(grid.Rows.Cast<DataGridViewRow>());
var partRow = grid.Rows.Cast<DataGridViewRow>().First(r => r.Cells[1].Value is int);
grid.ClearSelection();
partRow.Selected = true;
Assert.Contains("affected part extent", Field<Label>(panel, "previewLabel").Text);
Field<Button>(panel, "applyButton").PerformClick();
Assert.Null(panel.CommitResult);
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
accept.Checked = true;
Field<Button>(panel, "applyButton").PerformClick();
Assert.Equal(OpenNest.CNC.CuttingPlanning.CuttingCommitStatus.Applied, panel.CommitResult!.Status);
Assert.All(plate.Parts, part => Assert.True(part.HasManualLeadIns));
Assert.Null(editor.CuttingInspection);
});
[Fact]
@@ -242,6 +255,10 @@ public class CuttingInspectionPanelTests
Assert.Contains("Replan", Field<TextBox>(panel, "resultLabel").Text);
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
Assert.All(plate.Parts, p => Assert.False(p.HasManualLeadIns));
panel.StartPlanning();
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.True(Field<Button>(panel, "applyButton").Enabled);
Assert.True(editor.PlateView.HasProposalDisplay);
panel.RequestClose();
});
@@ -259,7 +276,18 @@ public class CuttingInspectionPanelTests
var proposed = panel.Proposal.Plates[0].Result.ProposedOrder
.Select(part => original[part.SourceOrdinal]).ToArray();
Assert.True(Field<Button>(panel, "applyButton").Enabled);
var parameters = Field<CuttingParameters>(panel, "parameters");
var reordered = 0;
editor.PlateView.PartsReordered += (_, _) => reordered++;
Field<Button>(panel, "applyButton").PerformClick();
Assert.Equal(1, reordered);
Assert.NotSame(parameters, plate.CuttingParameters);
foreach (var layout in editor.PlateView.LayoutParts)
{
if (layout.IsDirty)
layout.Update(editor.PlateView);
Assert.True(layout.LeadInPath?.PointCount > 0);
}
Assert.Null(editor.CuttingInspection);
Assert.False(editor.PlateView.InspectionOnly);
Assert.False(editor.PlateView.HasProposalDisplay);
@@ -304,7 +332,7 @@ public class CuttingInspectionPanelTests
panel.BeforePostApply = () => throw new InvalidOperationException("synthetic defaults failure");
panel.OnPostApplyError = ex => error = ex;
Field<Button>(panel, "applyButton").PerformClick();
Assert.Equal(OpenNest.CNC.CuttingPlanning.CuttingCommitStatus.Applied, panel.CommitResult.Status);
Assert.Equal(OpenNest.CNC.CuttingPlanning.CuttingCommitStatus.Applied, panel.CommitResult!.Status);
Assert.IsType<InvalidOperationException>(error);
Assert.Null(editor.CuttingInspection);
Assert.False(editor.PlateView.InspectionOnly);
@@ -312,6 +340,72 @@ public class CuttingInspectionPanelTests
Assert.All(editor.PlateView.Plate.Parts, part => Assert.True(part.HasManualLeadIns));
});
[Fact]
public void ConstructorKeepsItsOwnCopyOfSettings() => RunSta(() =>
{
using var editor = Editor();
var parameters = Parameters();
using var panel = new CuttingInspectionPanel(editor, parameters);
((LineLeadIn)parameters.ExternalLeadIn).Length = 5;
var captured = Field<CuttingParameters>(panel, "parameters");
Assert.NotSame(parameters, captured);
Assert.Equal(0.3, ((LineLeadIn)captured.ExternalLeadIn).Length);
});
[Fact]
public void EmptySentinelIsNotNumberedAsAnOccupiedPlate() => RunSta(() =>
{
using var editor = Editor();
editor.Show();
editor.PlateManager.LoadAt(editor.Nest.Plates.Count - 1);
editor.OpenCuttingInspection();
var panel = editor.CuttingInspection!;
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.Equal("New plate (empty)", Field<Label>(panel, "previewLabel").Text);
Assert.False(editor.PlateView.HasProposalDisplay);
panel.RequestClose();
});
[Fact]
public void BusyMenuRefusesPlanningAndFreeMenuOpensDockedPanel() => RunSta(() =>
{
using var host = new MenuHost();
using var editor = Editor();
host.Show();
editor.MdiParent = host;
editor.Show();
editor.Activate();
editor.PlateManager.LoadAt(0);
var busy = typeof(MainForm).GetField("nestingInProgress", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
var show = typeof(MainForm).GetMethod("ShowCuttingPlan", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
busy.SetValue(host, true);
Assert.True(host.IsCuttingPlanBusy([editor.PlateView]));
string? notice = null;
host.CuttingPlanNotice = (message, caption) =>
{
Assert.Equal("Plan Cutting", caption);
notice = message;
};
show.Invoke(host, [false]); // Exercise the real guarded menu path, not only its predicate.
Assert.Contains("Finish or cancel", notice);
Assert.Null(editor.CuttingInspection);
Assert.False(editor.PlateView.InspectionOnly);
busy.SetValue(host, false);
Assert.False(host.IsCuttingPlanBusy([editor.PlateView]));
// A completed DoEvents loop removes the ambient context; the panel still owns its dispatcher.
Application.DoEvents();
show.Invoke(host, [false]);
var panel = Assert.IsType<CuttingInspectionPanel>(editor.CuttingInspection);
PumpUntil(() => panel.Proposal != null && !panel.IsPlanning);
Assert.True(Field<Button>(panel, "applyButton").Enabled);
panel.RequestClose();
});
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, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)?.GetValue(target));
@@ -1,348 +0,0 @@
using System.Reflection;
using System.Windows.Forms;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Controls;
using OpenNest.Forms;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests.Forms;
// Shown forms and Application.OpenForms-based guards share this collection with the fill tests.
[Collection("Fill operation lifetime")]
public class CuttingPlanFormTests
{
[Fact]
public void PlanThenApply_InstallsThePlanAndRedrawsTheEditorInPlateOrder() => RunSta(() =>
{
var (nest, view) = CreateView(Square("a", 12, 1), Square("b", 1, 1));
using var editor = view;
var plate = view.Plate;
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters());
form.Show();
WaitForPlan(form);
Assert.True(Control<Button>(form, "applyButton").Enabled);
Assert.StartsWith("Ready to apply to 1 plate", Control<TextBox>(form, "summaryBox").Text);
var preview = Field<PlateView>(form, "preview");
Assert.True(preview.Visible);
Assert.NotSame(plate, preview.Plate);
Assert.Equal(2, preview.Plate.Parts.Count);
Assert.All(preview.Plate.Parts, part => Assert.DoesNotContain(part, plate.Parts));
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns)); // The preview is detached.
var reordered = 0;
view.PartsReordered += (_, _) => reordered++;
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
Assert.Equal(DialogResult.OK, form.DialogResult);
Assert.Equal(CuttingCommitStatus.Applied, form.CommitResult!.Status);
Assert.All(plate.Parts, part => Assert.True(part.HasManualLeadIns));
Assert.NotNull(plate.CuttingParameters);
Assert.NotSame(form.ConfirmedParameters, plate.CuttingParameters);
Assert.Equal(1, reordered);
Assert.Equal(plate.Parts, view.LayoutParts.Select(layout => layout.BasePart));
Assert.All(view.LayoutParts, layout => Assert.True(layout.IsDirty));
});
[Fact]
public void BlockedPlate_KeepsApplyDisabledAndShowsTheCurrentParts() => RunSta(() =>
{
var locked = Square("locked", 1, 1);
var crossing = (OpenNest.CNC.Program)locked.Program.Clone();
crossing.MoveTo(5, 5); // A locked crossing cannot be repaired by regeneration.
Assert.True(locked.RestoreLeadInProgram(crossing, true));
var (nest, view) = CreateView(locked);
using var editor = view;
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters());
form.Show();
WaitForPlan(form);
Assert.False(Control<Button>(form, "applyButton").Enabled);
var summary = Control<TextBox>(form, "summaryBox").Text;
Assert.Contains("Plate 1: blocked", 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.Contains("no current, usable preview", Control<Label>(form, "previewLabel").Text);
Assert.Null(view.Plate.CuttingParameters);
});
[Fact]
public void ClosingWhilePlanning_CancelsAndWaitsForTheWorker() => RunSta(() =>
{
var (nest, view) = CreateView(Square("a", 1, 1), Square("b", 12, 1));
using var editor = view;
var programs = view.Plate.Parts.Select(part => part.Program).ToArray();
using var entered = new ManualResetEventSlim();
using var release = new ManualResetEventSlim();
var cancelled = false;
var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters())
{
// Hold the worker until the close request has been checked.
BeforePlan = token =>
{
entered.Set();
release.Wait(TimeSpan.FromSeconds(30));
cancelled = token.IsCancellationRequested;
},
};
try
{
form.Show();
PumpUntil(() => form.IsPlanning, "planning to start");
Assert.True(entered.Wait(TimeSpan.FromSeconds(30)), "The worker did not start.");
form.Close();
// The worker still holds the plan; the dialog stays up, cancelling, until it stops.
Assert.False(form.IsDisposed);
Assert.True(form.Visible);
Assert.True(form.IsPlanning);
Assert.False(Control<Button>(form, "cancelButton").Enabled);
Assert.False(Control<Button>(form, "planButton").Enabled);
release.Set();
PumpUntil(() => form.IsDisposed, "the dialog to close after cancelling");
Observe(form);
Assert.True(cancelled);
Assert.Equal(programs, view.Plate.Parts.Select(part => part.Program));
Assert.All(view.Plate.Parts, part => Assert.False(part.HasManualLeadIns));
Assert.Null(view.Plate.CuttingParameters);
}
finally
{
release.Set();
form.Dispose();
}
});
[Fact]
public void Constructor_PlansWithItsOwnCopyOfTheSettings() => RunSta(() =>
{
var (nest, view) = CreateView(Square("a", 1, 1));
using var editor = view;
var parameters = Parameters();
using var form = new CuttingPlanForm(view, nest, allPlates: false, parameters);
((LineLeadIn)parameters.ExternalLeadIn).Length = 5;
Assert.NotSame(parameters, form.ConfirmedParameters);
Assert.Equal(0.3, ((LineLeadIn)form.ConfirmedParameters.ExternalLeadIn).Length);
});
[Fact]
public void ApplyAfterALiveEdit_ChangesNothingAndReplanRecovers() => RunSta(() =>
{
var (nest, view) = CreateView(Square("a", 1, 1), Square("b", 12, 1));
using var editor = view;
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters());
form.Show();
WaitForPlan(form);
var programs = view.Plate.Parts.Select(part => part.Program).ToArray();
view.Plate.Parts[1].Offset(0, 1);
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
Assert.False(form.IsDisposed);
Assert.Equal(DialogResult.None, form.DialogResult);
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);
Invoke(form, "PlanButton_Click", null, EventArgs.Empty);
WaitForPlan(form);
Assert.True(Control<Button>(form, "applyButton").Enabled);
});
[Fact]
public void BusyEditor_DoesNotStartPlanningUntilItIsFree() => RunSta(() =>
{
var busy = true;
var (nest, view) = CreateView(Square("a", 1, 1));
using var editor = view;
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters(), () => busy);
Invoke(form, "StartPlanning");
Assert.False(form.IsPlanning);
Assert.Null(form.Proposal);
Assert.Contains("Wait for the current nesting or plate action", Control<TextBox>(form, "summaryBox").Text);
busy = false;
// Ending a DoEvents loop uninstalls WinForms' ambient context; planning started outside a
// message loop must still report progress and results on the dialog's thread.
Application.DoEvents();
Invoke(form, "StartPlanning");
WaitForPlan(form);
Assert.True(Control<Button>(form, "applyButton").Enabled);
});
[Fact]
public void AllPlates_SkipsEmptyPlatesAndRefusesTheBatchWhenOnePlateIsBlocked() => RunSta(() =>
{
var nest = new Nest();
var first = nest.CreatePlate();
first.Parts.Add(Square("open", 1, 1));
var second = nest.CreatePlate();
var locked = Square("locked", 1, 1);
var crossing = (OpenNest.CNC.Program)locked.Program.Clone();
crossing.MoveTo(5, 5);
Assert.True(locked.RestoreLeadInProgram(crossing, true));
second.Parts.Add(locked);
nest.CreatePlate(); // An empty plate, like the editor's trailing plate.
using var view = new PlateView { Plate = first };
using var form = new CuttingPlanForm(view, nest, allPlates: true, Parameters());
form.Show();
WaitForPlan(form);
Assert.Equal("Plan Cutting — All Plates", form.Text);
Assert.Equal(2, form.Proposal!.Plates.Count);
var summary = Control<TextBox>(form, "summaryBox").Text;
Assert.Contains("Plate 1: ready.", summary);
Assert.Contains("Plate 2: blocked", summary);
Assert.DoesNotContain("Plate 3", summary);
Assert.False(Control<Button>(form, "applyButton").Enabled);
Assert.All(first.Parts, part => Assert.False(part.HasManualLeadIns));
});
[Fact]
public void OnePlateDialog_ExcludesTheSentinelFromTheShownTotal() => RunSta(() =>
{
var (nest, view) = CreateView(Square("a", 1, 1));
nest.CreatePlate(); // The editor's trailing empty new-plate sentinel.
using var editor = view;
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters());
Assert.StartsWith("Plate 1 of 1, numbered in cutting order:", Control<Label>(form, "previewLabel").Text);
});
[Fact]
public void SentinelActivePlate_IsLabeledNotNumberedBeyondTheTotal() => RunSta(() =>
{
var (nest, _) = CreateView(Square("a", 1, 1));
var sentinel = nest.CreatePlate(); // trailing empty new-plate sentinel
using var view = new PlateView { Plate = sentinel };
using var editor = view;
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters());
var text = Control<Label>(form, "previewLabel").Text;
Assert.StartsWith("New plate (empty)", text);
Assert.DoesNotContain("Plate 2", text);
});
[Fact]
public void UnverifiedPlan_RequiresAcceptanceAndReplanClearsIt() => RunSta(() =>
{
var program = SquareProgram();
program.MoveTo(2, 2); program.LineTo(4, 2); program.LineTo(4, 4);
program.LineTo(2, 4); program.LineTo(2, 2);
program.MoveTo(4, 2); program.LineTo(6, 2); program.LineTo(6, 4);
program.LineTo(4, 4); program.LineTo(4, 2);
var part = new Part(new Drawing("touching contours", program), new Vector(1, 1));
var (nest, view) = CreateView(part);
using var editor = view;
using var form = new CuttingPlanForm(view, nest, allPlates: false, Parameters());
form.Show();
WaitForPlan(form);
var accept = Control<CheckBox>(form, "acceptWarningsCheckBox");
Assert.True(accept.Visible);
Assert.False(accept.Checked);
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;
Invoke(form, "ApplyButton_Click", null, EventArgs.Empty);
Assert.Equal(CuttingCommitStatus.Applied, form.CommitResult!.Status);
Assert.True(part.HasManualLeadIns);
});
private static (Nest Nest, PlateView View) CreateView(params Part[] parts)
{
var nest = new Nest();
var plate = nest.CreatePlate();
plate.Size = new Size(100, 100);
foreach (var part in parts)
plate.Parts.Add(part);
return (nest, new PlateView { Plate = plate });
}
private static CuttingParameters Parameters() => new()
{
ExternalLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 45 },
InternalLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 45 },
ArcCircleLeadIn = new LineLeadIn { Length = 0.3, ApproachAngle = 45 },
ExternalLeadOut = new NoLeadOut(),
InternalLeadOut = new NoLeadOut(),
ArcCircleLeadOut = new NoLeadOut(),
};
private static Part Square(string name, double x, double y) =>
new(new Drawing(name, SquareProgram()), new Vector(x, y));
private static CNC.Program SquareProgram()
{
var program = new CNC.Program();
program.MoveTo(0, 0);
program.LineTo(0, 10);
program.LineTo(10, 10);
program.LineTo(10, 0);
program.LineTo(0, 0);
return program;
}
private static void WaitForPlan(CuttingPlanForm form)
{
PumpUntil(() => form.Proposal != null && !form.IsPlanning, "the plan");
Observe(form);
}
// The planning task is already complete here; this rethrows anything its completion raised.
private static void Observe(CuttingPlanForm form) => form.PlanningTask.GetAwaiter().GetResult();
private static void PumpUntil(Func<bool> condition, string what)
{
var until = DateTime.UtcNow.AddSeconds(60);
while (!condition() && DateTime.UtcNow < until)
{
Application.DoEvents();
Thread.Sleep(10);
}
Assert.True(condition(), $"Timed out waiting for {what}.");
}
private static T Control<T>(Form form, string name) where T : Control =>
Assert.IsType<T>(form.Controls.Find(name, true).Single());
private static T Field<T>(object owner, string name) =>
(T)owner.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(owner)!;
private static void Invoke(CuttingPlanForm form, string method, params object?[] arguments) =>
typeof(CuttingPlanForm).GetMethod(method, BindingFlags.Instance | BindingFlags.NonPublic)!
.Invoke(form, arguments);
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromMinutes(3), "The STA test did not complete.");
}
@@ -0,0 +1,19 @@
using OpenNest.Forms;
namespace OpenNest.WinForms.Tests.Forms;
public class CuttingUiRetirementTests
{
[Fact]
public void EditorAssemblyHasOnlyTheDockedInspectionEntryPoints()
{
var assembly = typeof(EditNestForm).Assembly;
Assert.Null(assembly.GetType("OpenNest.Forms.CuttingPlanForm"));
Assert.Null(assembly.GetType("OpenNest.Forms.ValidateNestForm"));
Assert.Null(typeof(EditNestForm).GetMethod("PlanCutting"));
// Non-editor consumers retain the shared analyzer, not an editor-dependent inspector.
Assert.Contains(typeof(OpenNest.Diagnostics.PostVerificationAnalyzer).GetMethods(),
method => method.Name == "Analyze" && method.GetParameters().Length > 0
&& method.GetParameters()[0].ParameterType == typeof(Nest));
}
}
@@ -225,6 +225,55 @@ public class CuttingValidationPanelTests
}
});
[Fact]
public void OverlapNavigationKeepsAllPlateProgramsAndPosesStable() => RunSta(() =>
{
using var editor = RapidEditor();
var plate = editor.Nest.Plates[1];
plate.Parts.Add(new Part(plate.Parts[0].BaseDrawing) { Location = new Vector(2, 2) });
editor.Show();
editor.PlateManager.LoadAt(0);
var parts = editor.Nest.Plates.SelectMany(p => p.Parts).ToArray();
var programs = parts.Select(p => p.Program).ToArray();
var codes = programs.Select(p => p.Codes.ToArray()).ToArray();
var locations = parts.Select(p => p.Location).ToArray();
var rotations = parts.Select(p => p.Rotation).ToArray();
editor.OpenValidationInspection();
var panel = editor.CuttingInspection!;
Pump(() => panel.ValidationReport != null && !panel.IsPlanning);
var grid = Field<DataGridView>(panel, "warningGrid");
Assert.True(grid.ReadOnly);
Assert.Equal(DataGridViewSelectionMode.FullRowSelect, grid.SelectionMode);
Assert.All(grid.Columns.Cast<DataGridViewColumn>(), c => Assert.Equal(DataGridViewColumnSortMode.NotSortable, c.SortMode));
Assert.True(Field<TextBox>(panel, "resultLabel").ReadOnly);
var row = grid.Rows.Cast<DataGridViewRow>().Single(r => r.Tag is PostVerificationFinding { Kind: PostVerificationKind.Overlap });
row.Selected = true;
var view = editor.PlateView;
Assert.Same(plate, view.Plate);
Assert.NotNull(view.ValidationOverlay.Finding.Overlap);
Assert.Contains("centroid", Field<TextBox>(panel, "resultLabel").Text);
var scale = view.ViewScale;
InvokeView(view, "OnMouseWheel", new MouseEventArgs(MouseButtons.None, 0, 100, 100, 120));
Assert.True(view.ViewScale > scale);
var viewport = view.CaptureViewport();
InvokeView(view, "OnMouseMove", new MouseEventArgs(MouseButtons.Middle, 0, 120, 110, 0));
Assert.NotEqual(viewport.Origin, view.CaptureViewport().Origin);
view.SelectAll();
InvokeView(view, "OnKeyDown", new KeyEventArgs(Keys.Delete));
InvokeView(view, "ProcessDialogKey", Keys.Right);
panel.RequestClose();
Assert.Equal(parts, editor.Nest.Plates.SelectMany(p => p.Parts));
Assert.Equal(programs, parts.Select(p => p.Program));
for (var i = 0; i < programs.Length; i++) Assert.Equal(codes[i], programs[i].Codes.ToArray());
Assert.Equal(locations, parts.Select(p => p.Location));
Assert.Equal(rotations, parts.Select(p => p.Rotation));
Assert.Null(view.ValidationOverlay.Finding);
Assert.False(view.ValidationOverlay.IsAnimating);
});
private static void InvokeView(Control target, string name, params object[] args) => target.GetType()
.GetMethod(name, BindingFlags.NonPublic | BindingFlags.Instance)!.Invoke(target, args);
private static EditNestForm RapidEditor()
{
var nest = new Nest();
@@ -1,302 +0,0 @@
using System.Drawing;
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.CNC;
using OpenNest.Controls;
using OpenNest.Diagnostics;
using OpenNest.Forms;
using OpenNest.Geometry;
namespace OpenNest.WinForms.Tests.Forms;
public class ValidateNestFormTests
{
[Fact]
public void RapidContactCrosshairIsDrawnAtContactAndClearedWithFinding()
{
RunSta(() =>
{
using var view = new PlateView();
view.ShowValidationFinding(new PostVerificationFinding(PostVerificationKind.RapidCrossing,
1, 2, 1, "crossing")
{
RapidStart = new Vector(-1, 0),
RapidEnd = new Vector(1, 0),
ContactPoints = Array.AsReadOnly(new[] { Vector.Zero })
});
using var bitmap = new Bitmap(120, 120);
using var graphics = Graphics.FromImage(bitmap);
graphics.TranslateTransform(60, 60);
graphics.Clear(Color.Black);
view.ValidationOverlay.Draw(graphics);
// Off the horizontal rapid, on the contact's vertical crosshair.
var pixel = bitmap.GetPixel(60, 65);
Assert.True(pixel.R > 150 && pixel.B > 50 && pixel.G < 100);
view.ClearValidationFinding();
graphics.Clear(Color.Black);
view.ValidationOverlay.Draw(graphics);
Assert.Equal(Color.Black.ToArgb(), bitmap.GetPixel(60, 65).ToArgb());
});
}
#pragma warning disable xUnit1031 // Completed tasks on a dedicated STA thread.
[Fact]
public void GridNavigatesMainViewAndRestoresViewportOnDisposal()
{
RunSta(() =>
{
var nest = new Nest("interactive inspection");
var program = new CNC.Program();
program.MoveTo(0, 0);
program.LineTo(5, 0);
program.LineTo(5, 5);
program.LineTo(0, 5);
program.LineTo(0, 0);
nest.CreatePlate().Parts.Add(new Part(new Drawing("first", program)));
var second = nest.CreatePlate();
second.Parts.Add(new Part(new Drawing("second", program)));
second.Parts.Add(new Part(second.Parts[0].BaseDrawing) { Location = new Vector(2, 2) });
using var editor = new EditNestForm(nest);
editor.Show();
editor.PlateManager.LoadAt(0);
var view = editor.PlateView;
view.ZoomToArea(-10, -10, 40, 40);
var viewport = view.CaptureViewport();
view.OverlapDisplay = OverlapDisplayMode.Centroids;
using (var form = new ValidateNestForm(editor,
(value, token) => Task.FromResult(PostVerificationAnalyzer.Analyze(value, token))))
{
form.Show(editor);
form.ValidateAsync().GetAwaiter().GetResult();
Assert.True(form.TopLevel);
Assert.False(form.Modal);
Assert.True(editor.Enabled);
Assert.True(view.Enabled);
Assert.True(view.InspectionOnly);
var grid = Find<DataGridView>(form, "warningGrid");
Assert.True(grid.ReadOnly);
Assert.Equal(DataGridViewSelectionMode.FullRowSelect, grid.SelectionMode);
var row = grid.Rows.Cast<DataGridViewRow>().Single(row =>
row.Tag is PostVerificationFinding { Kind: PostVerificationKind.Overlap });
grid.CurrentCell = row.Cells[0];
Assert.Same(second, view.Plate);
Assert.Equal(1, editor.PlateManager.CurrentIndex);
Assert.NotNull(view.ValidationOverlay.Finding.Overlap);
Assert.True(view.ValidationOverlay.IsAnimating);
using var bitmap = new Bitmap(view.Width, view.Height);
view.DrawToBitmap(bitmap, view.ClientRectangle);
Assert.Contains("centroid", Find<TextBox>(form, "reportBox").Text);
view.SelectAll();
var scale = view.ViewScale;
Invoke(view, "OnMouseWheel", new MouseEventArgs(MouseButtons.None, 0, 100, 100, 120));
Assert.True(view.ViewScale > scale);
var pan = view.CaptureViewport();
Invoke(view, "OnMouseMove", new MouseEventArgs(MouseButtons.Middle, 0, 120, 110, 0));
Assert.NotEqual(pan.Origin, view.CaptureViewport().Origin);
Invoke(view, "OnKeyDown", new KeyEventArgs(Keys.Delete));
Invoke(view, "ProcessDialogKey", Keys.Right);
Invoke(view, "OnMouseDown", new MouseEventArgs(MouseButtons.Middle, 1, 120, 110, 0));
Invoke(view, "OnMouseUp", new MouseEventArgs(MouseButtons.Middle, 1, 120, 110, 0));
Assert.Equal(2, second.Parts.Count);
Assert.All(second.Parts, part => Assert.Equal(0, part.Rotation));
}
Assert.Same(nest.Plates[0], view.Plate);
Assert.Equal(viewport, view.CaptureViewport());
Assert.Equal(OverlapDisplayMode.Centroids, view.OverlapDisplay);
Assert.Null(view.ValidationOverlay.Finding);
Assert.False(view.ValidationOverlay.IsAnimating);
Assert.False(view.InspectionOnly);
Assert.Equal(new Vector(2, 2), second.Parts[1].Location);
});
}
[Fact]
public void ModelessCancellationKeepsEditingLockedUntilWorkerStops()
{
RunSta(() =>
{
var pending = new TaskCompletionSource<PostVerificationReport>();
using var editor = new EditNestForm(new Nest("inspection"));
editor.Show();
using var form = new ValidateNestForm(editor, (_, _) => pending.Task);
form.Show(editor);
var validation = form.ValidateAsync();
Assert.False(validation.IsCompleted);
Assert.True(editor.Enabled);
Assert.True(editor.PlateView.Enabled);
Assert.True(editor.PlateView.InspectionOnly);
form.Close();
Assert.True(form.Visible);
Assert.True(editor.PlateView.InspectionOnly);
pending.SetCanceled();
var timeout = System.Diagnostics.Stopwatch.StartNew();
while (!form.IsDisposed && timeout.Elapsed < TimeSpan.FromSeconds(5))
Application.DoEvents();
Assert.True(form.IsDisposed);
Assert.False(editor.PlateView.InspectionOnly);
});
}
[Fact]
public void PlateSwitchClearsAnimation()
{
RunSta(() =>
{
using var view = new PlateView();
view.ShowValidationFinding(new PostVerificationFinding(PostVerificationKind.MissingLeadIn,
1, 1, null, "missing")
{ Location = new Vector(3, 4) });
Assert.True(view.ValidationOverlay.IsAnimating);
view.Plate = new Plate();
Assert.False(view.ValidationOverlay.IsAnimating);
Assert.Null(view.ValidationOverlay.Finding);
});
}
[Fact]
public void ChecksAllPlatesWithoutChangingProgramsOrPlacements()
{
RunSta(() =>
{
var nest = new Nest("PEP inspection");
var program = new CNC.Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(5, 0));
program.Codes.Add(new LinearMove(5, 5));
program.Codes.Add(new LinearMove(0, 5));
program.Codes.Add(new LinearMove(0, 0));
var first = new Part(new Drawing("square", program));
var second = new Part(first.BaseDrawing);
nest.CreatePlate().Parts.Add(first);
nest.CreatePlate().Parts.Add(second);
var firstProgram = first.Program;
var secondProgram = second.Program;
var firstCodes = firstProgram.Codes.ToArray();
var location = first.Location;
var rotation = first.Rotation;
using var form = new ValidateNestForm(nest,
(value, token) => Task.FromResult(PostVerificationAnalyzer.Analyze(value, token)));
form.ValidateAsync().GetAwaiter().GetResult();
var report = Find<TextBox>(form, "reportBox");
Assert.True(report.ReadOnly);
Assert.Contains("Plate 1, part 1", report.Text);
Assert.Contains("Plate 2, part 1", report.Text);
Assert.DoesNotContain("Post processor", report.Text);
Assert.Contains("Warnings found", Find<Label>(form, "summaryLabel").Text);
Assert.Same(first, nest.Plates[0].Parts[0]);
Assert.Same(second, nest.Plates[1].Parts[0]);
Assert.Same(firstProgram, first.Program);
Assert.Same(secondProgram, second.Program);
Assert.Equal(firstCodes, first.Program.Codes.ToArray());
Assert.Equal(location, first.Location);
Assert.Equal(rotation, first.Rotation);
Assert.Same(Find<Button>(form, "closeButton"), form.AcceptButton);
Assert.Same(form.AcceptButton, form.CancelButton);
Assert.Empty(form.Controls.Find("postButton", true));
});
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void DistinguishesClearAndIncompleteChecks(bool incomplete)
{
RunSta(() =>
{
var nest = new Nest("inspection");
if (incomplete)
nest.CreatePlate().Parts.Add(new Part(new Drawing("invalid", new CNC.Program())));
using var form = new ValidateNestForm(nest,
(value, token) => Task.FromResult(PostVerificationAnalyzer.Analyze(value, token)));
form.ValidateAsync().GetAwaiter().GetResult();
Assert.Contains(incomplete ? "Validation incomplete" : "No warnings found",
Find<Label>(form, "summaryLabel").Text);
});
}
[Fact]
public void FailureIsReportedWithoutAnAllClear()
{
RunSta(() =>
{
using var form = new ValidateNestForm(new Nest("inspection"),
(_, _) => Task.FromException<PostVerificationReport>(new InvalidOperationException("test failure")));
form.ValidateAsync().GetAwaiter().GetResult();
Assert.Contains("Validation failed", Find<Label>(form, "summaryLabel").Text);
Assert.Contains("test failure", Find<TextBox>(form, "reportBox").Text);
Assert.Equal("Close", Find<Button>(form, "closeButton").Text);
});
}
[Theory]
[InlineData("Cancel")]
[InlineData("Escape")]
[InlineData("Close")]
public void RealModalCancellationKeepsOwnerDisabledUntilWorkerStops(string action)
{
RunSta(() =>
{
var pending = new TaskCompletionSource<PostVerificationReport>();
using var owner = new Form();
using var form = new ValidateNestForm(new Nest("inspection"), (_, _) => pending.Task);
Exception? failure = null;
owner.Show();
using var timeout = new System.Windows.Forms.Timer { Interval = 10000 };
timeout.Tick += (_, _) =>
{
failure ??= new TimeoutException("Modal cancellation did not finish.");
pending.TrySetCanceled();
form.Close();
};
form.Shown += (_, _) => form.BeginInvoke(new System.Action(() =>
{
try
{
Assert.False(IsWindowEnabled(owner.Handle));
if (action == "Cancel")
Find<Button>(form, "closeButton").PerformClick();
else if (action == "Escape")
Invoke(form, "ProcessDialogKey", Keys.Escape);
else
form.Close();
// ShowDialog must stay active while the worker ignores cancellation.
Assert.True(form.Visible);
Assert.False(IsWindowEnabled(owner.Handle));
Assert.False(Find<Button>(form, "closeButton").Enabled);
}
catch (Exception ex)
{
failure = ex;
}
finally
{
pending.TrySetCanceled();
}
}));
timeout.Start();
var result = form.ShowDialog(owner);
timeout.Stop();
Assert.Equal(DialogResult.Cancel, result);
Assert.True(IsWindowEnabled(owner.Handle));
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
});
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
private static extern bool IsWindowEnabled(IntPtr window);
private static void Invoke(Control form, string method, params object?[] arguments) =>
form.GetType().GetMethod(method, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.Invoke(form, arguments);
private static void RunSta(System.Action action) =>
StaTestThread.Run(action, TimeSpan.FromSeconds(30), "STA validation test timed out.");
private static T Find<T>(Form form, string name) where T : Control =>
Assert.IsType<T>(form.Controls.Find(name, true).Single());
}
+8
View File
@@ -431,6 +431,7 @@ internal sealed class CuttingInspectionPanel : UserControl
}
private void UpdateApply() => applyButton.Enabled = Mode == CuttingInspectionMode.Plan && !invalidated && !IsPlanning
&& plates.Any(item => item.Parts.Count > 0)
&& (Proposal?.CanApply == true || Proposal?.CanApplyWithWarnings == true && acceptWarningsCheckBox.Checked);
private bool IsPreviewCurrent()
@@ -453,6 +454,13 @@ internal sealed class CuttingInspectionPanel : UserControl
selectedPlate = index;
view.ClearProposalDisplay();
view.ClearValidationFinding();
if (plates[index].Parts.Count == 0)
{
preview = null;
previewLabel.Text = "New plate (empty)";
SetPlanning(false);
return;
}
preview = inspection.RefreshPreview(index); // Only the guarded authority may clone programs.
SetPlanning(false);
if (preview == null)
-291
View File
@@ -1,291 +0,0 @@
using System.Drawing;
using System.Windows.Forms;
namespace OpenNest.Forms;
partial class CuttingPlanForm
{
private System.ComponentModel.IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing)
{
planning?.Cancel();
components?.Dispose();
}
base.Dispose(disposing);
}
private void InitializeComponent()
{
layout = new TableLayoutPanel();
settingsPanel = new TableLayoutPanel();
settingsLabel = new Label();
settingsButton = new Button();
optionsPanel = new FlowLayoutPanel();
keepOrderCheckBox = new CheckBox();
acceptWarningsCheckBox = new CheckBox();
planButton = new Button();
split = new SplitContainer();
previewLayout = new TableLayoutPanel();
previewLabel = new Label();
previewPanel = new Panel();
summaryBox = new TextBox();
statusLabel = new Label();
buttonsPanel = new FlowLayoutPanel();
applyButton = new Button();
cancelButton = new Button();
layout.SuspendLayout();
settingsPanel.SuspendLayout();
optionsPanel.SuspendLayout();
((System.ComponentModel.ISupportInitialize)split).BeginInit();
split.Panel1.SuspendLayout();
split.Panel2.SuspendLayout();
split.SuspendLayout();
previewLayout.SuspendLayout();
buttonsPanel.SuspendLayout();
SuspendLayout();
//
// layout
//
layout.ColumnCount = 1;
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
layout.Controls.Add(settingsPanel, 0, 0);
layout.Controls.Add(optionsPanel, 0, 1);
layout.Controls.Add(split, 0, 2);
layout.Controls.Add(statusLabel, 0, 3);
layout.Controls.Add(acceptWarningsCheckBox, 0, 4);
layout.Controls.Add(buttonsPanel, 0, 5);
layout.Dock = DockStyle.Fill;
layout.Name = "layout";
layout.RowCount = 6;
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.TabIndex = 0;
//
// settingsPanel
//
settingsPanel.AutoSize = true;
settingsPanel.ColumnCount = 2;
settingsPanel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
settingsPanel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
settingsPanel.Controls.Add(settingsLabel, 0, 0);
settingsPanel.Controls.Add(settingsButton, 1, 0);
settingsPanel.Dock = DockStyle.Fill;
settingsPanel.Margin = new Padding(0, 0, 0, 4);
settingsPanel.Name = "settingsPanel";
settingsPanel.RowCount = 1;
settingsPanel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
settingsPanel.TabIndex = 0;
//
// settingsLabel
//
settingsLabel.Anchor = AnchorStyles.Left | AnchorStyles.Right;
settingsLabel.AutoSize = true;
settingsLabel.Name = "settingsLabel";
settingsLabel.TabIndex = 0;
settingsLabel.Text = "Lead-ins:";
//
// settingsButton
//
settingsButton.AutoSize = true;
settingsButton.Name = "settingsButton";
settingsButton.Size = new Size(130, 30);
settingsButton.TabIndex = 1;
settingsButton.Text = "Cutting &Settings...";
settingsButton.UseVisualStyleBackColor = true;
settingsButton.Click += SettingsButton_Click;
//
// optionsPanel
//
optionsPanel.AutoSize = true;
optionsPanel.Controls.Add(keepOrderCheckBox);
optionsPanel.Controls.Add(planButton);
optionsPanel.Dock = DockStyle.Fill;
optionsPanel.Margin = new Padding(0, 0, 0, 4);
optionsPanel.Name = "optionsPanel";
optionsPanel.TabIndex = 1;
optionsPanel.WrapContents = false;
//
// keepOrderCheckBox
//
keepOrderCheckBox.Anchor = AnchorStyles.Left;
keepOrderCheckBox.AutoSize = true;
keepOrderCheckBox.Margin = new Padding(3, 3, 12, 3);
keepOrderCheckBox.Name = "keepOrderCheckBox";
keepOrderCheckBox.TabIndex = 0;
keepOrderCheckBox.Text = "&Keep the current part order";
keepOrderCheckBox.UseVisualStyleBackColor = true;
keepOrderCheckBox.CheckedChanged += KeepOrderCheckBox_CheckedChanged;
//
// planButton
//
planButton.AutoSize = true;
planButton.Name = "planButton";
planButton.Size = new Size(85, 30);
planButton.TabIndex = 1;
planButton.Text = "&Replan";
planButton.UseVisualStyleBackColor = true;
planButton.Click += PlanButton_Click;
//
// split
//
split.Dock = DockStyle.Fill;
split.Name = "split";
split.TabIndex = 2;
//
// split.Panel1
//
split.Panel1.Controls.Add(previewLayout);
//
// split.Panel2
//
split.Panel2.Controls.Add(summaryBox);
//
// previewLayout
//
previewLayout.ColumnCount = 1;
previewLayout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F));
previewLayout.Controls.Add(previewLabel, 0, 0);
previewLayout.Controls.Add(previewPanel, 0, 1);
previewLayout.Dock = DockStyle.Fill;
previewLayout.Margin = new Padding(0);
previewLayout.Name = "previewLayout";
previewLayout.RowCount = 2;
previewLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
previewLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100F));
previewLayout.TabIndex = 0;
//
// previewLabel
//
previewLabel.AutoSize = true;
previewLabel.Dock = DockStyle.Fill;
previewLabel.Name = "previewLabel";
previewLabel.TabIndex = 0;
previewLabel.Text = "Preview:";
//
// previewPanel
//
previewPanel.BorderStyle = BorderStyle.FixedSingle;
previewPanel.Dock = DockStyle.Fill;
previewPanel.Name = "previewPanel";
previewPanel.TabIndex = 1;
//
// summaryBox
//
summaryBox.Dock = DockStyle.Fill;
summaryBox.Multiline = true;
summaryBox.Name = "summaryBox";
summaryBox.ReadOnly = true;
summaryBox.ScrollBars = ScrollBars.Vertical;
summaryBox.TabIndex = 0;
//
// statusLabel
//
statusLabel.AutoSize = true;
statusLabel.Dock = DockStyle.Fill;
statusLabel.Margin = new Padding(3, 6, 3, 0);
statusLabel.Name = "statusLabel";
statusLabel.TabIndex = 3;
//
// acceptWarningsCheckBox
//
acceptWarningsCheckBox.AutoSize = true;
acceptWarningsCheckBox.Dock = DockStyle.Fill;
acceptWarningsCheckBox.Name = "acceptWarningsCheckBox";
acceptWarningsCheckBox.Text = "I reviewed the warnings. Apply this unverified plan.";
acceptWarningsCheckBox.Visible = false;
acceptWarningsCheckBox.TabIndex = 4;
acceptWarningsCheckBox.CheckedChanged += AcceptWarningsCheckBox_CheckedChanged;
//
// buttonsPanel
//
buttonsPanel.AutoSize = true;
buttonsPanel.Controls.Add(cancelButton);
buttonsPanel.Controls.Add(applyButton);
buttonsPanel.Dock = DockStyle.Fill;
buttonsPanel.FlowDirection = FlowDirection.RightToLeft;
buttonsPanel.Margin = new Padding(0, 8, 0, 0);
buttonsPanel.Name = "buttonsPanel";
buttonsPanel.TabIndex = 5;
buttonsPanel.WrapContents = false;
//
// applyButton
//
applyButton.AutoSize = true;
applyButton.Enabled = false;
applyButton.Name = "applyButton";
applyButton.Size = new Size(85, 30);
applyButton.TabIndex = 1;
applyButton.Text = "&Apply";
applyButton.UseVisualStyleBackColor = true;
applyButton.Click += ApplyButton_Click;
//
// cancelButton
//
cancelButton.AutoSize = true;
cancelButton.DialogResult = DialogResult.Cancel;
cancelButton.Name = "cancelButton";
cancelButton.Size = new Size(85, 30);
cancelButton.TabIndex = 0;
cancelButton.Text = "Cancel";
cancelButton.UseVisualStyleBackColor = true;
//
// CuttingPlanForm
//
AcceptButton = planButton;
AutoScaleDimensions = new SizeF(7F, 15F);
AutoScaleMode = AutoScaleMode.Font;
CancelButton = cancelButton;
ClientSize = new Size(940, 620);
Controls.Add(layout);
Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point);
MinimizeBox = false;
MinimumSize = new Size(700, 480);
Name = "CuttingPlanForm";
Padding = new Padding(12);
ShowIcon = false;
ShowInTaskbar = false;
StartPosition = FormStartPosition.CenterParent;
Text = "Plan Cutting";
layout.ResumeLayout(false);
layout.PerformLayout();
settingsPanel.ResumeLayout(false);
settingsPanel.PerformLayout();
optionsPanel.ResumeLayout(false);
optionsPanel.PerformLayout();
split.Panel1.ResumeLayout(false);
split.Panel2.ResumeLayout(false);
split.Panel2.PerformLayout();
((System.ComponentModel.ISupportInitialize)split).EndInit();
split.ResumeLayout(false);
previewLayout.ResumeLayout(false);
previewLayout.PerformLayout();
buttonsPanel.ResumeLayout(false);
buttonsPanel.PerformLayout();
ResumeLayout(false);
}
private TableLayoutPanel layout;
private TableLayoutPanel settingsPanel;
private Label settingsLabel;
private Button settingsButton;
private FlowLayoutPanel optionsPanel;
private CheckBox keepOrderCheckBox;
private CheckBox acceptWarningsCheckBox;
private Button planButton;
private SplitContainer split;
private TableLayoutPanel previewLayout;
private Label previewLabel;
private Panel previewPanel;
private TextBox summaryBox;
private Label statusLabel;
private FlowLayoutPanel buttonsPanel;
private Button applyButton;
private Button cancelButton;
}
-514
View File
@@ -1,514 +0,0 @@
using System;
using System.Drawing;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.CNC.CuttingPlanning;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Controls;
using OpenNest.Engine.CuttingPlanning;
namespace OpenNest.Forms;
/// <summary>
/// Plans part order and lead-ins for the active plate or every plate, previews the result and
/// applies it all-or-nothing through the cutting planner. Nothing live changes before Apply.
/// </summary>
public partial class CuttingPlanForm : Form
{
private readonly PlateView plateView;
private readonly Plate activePlate;
private readonly Plate[] plates;
private readonly int[] plateNumbers;
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;
private CancellationTokenSource planning;
private CuttingPlanProposal proposal;
private int generation;
private bool closeRequested;
/// <param name="plateView">The editor's view of the active plate.</param>
/// <param name="nest">The nest that owns the active plate.</param>
/// <param name="allPlates">Plan every plate that has parts instead of only the active plate.</param>
/// <param name="parameters">Cutting settings to start from; the form keeps its own copy.</param>
/// <param name="isOperationBusy">Reports a nesting or plate operation that must finish first.</param>
public CuttingPlanForm(PlateView plateView, Nest nest, bool allPlates, CuttingParameters parameters,
Func<bool> isOperationBusy = null)
{
this.plateView = plateView ?? throw new ArgumentNullException(nameof(plateView));
ArgumentNullException.ThrowIfNull(nest);
ArgumentNullException.ThrowIfNull(parameters);
// An owned copy: later edits to the caller's settings objects cannot change this plan.
this.parameters = CuttingParametersSerializer.Deserialize(CuttingParametersSerializer.Serialize(parameters));
activePlate = plateView.Plate ?? throw new ArgumentException("An active plate is required.", nameof(plateView));
var all = nest.Plates.ToList();
if (!all.Contains(activePlate))
throw new ArgumentException("The active plate is not in this nest.", nameof(nest));
plates = allPlates ? all.Where(p => p.Parts.Count > 0).ToArray() : new[] { activePlate };
if (plates.Length == 0)
plates = new[] { activePlate };
plateNumbers = plates.Select(p => all.IndexOf(p) + 1).ToArray();
this.isOperationBusy = isOperationBusy;
unit = UnitsHelper.GetShortString(nest.Units);
InitializeComponent();
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;
uiContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext();
preview = new PlateView
{
Dock = DockStyle.Fill,
AllowSelect = false,
AllowDrop = false,
DrawOrigin = false,
DrawRapid = true,
Cursor = Cursors.Default,
TabStop = false,
Visible = false,
};
preview.SizeChanged += (_, _) =>
{
if (preview.Visible)
preview.ZoomToFit();
};
previewPanel.Controls.Add(preview);
if (allPlates)
{
Text = "Plan Cutting — All Plates";
applyButton.Text = "&Apply to All Plates";
}
// Display-only numbering: the trailing empty new-plate sentinel is excluded from the
// "of M" total; plateNumbers keep real collection positions for the batch summary.
var displayCount = PlateDisplayNumbering.DisplayedPlateCount(all);
var activeNumber = PlateDisplayNumbering.DisplayedPlateNumber(all, all.IndexOf(activePlate));
previewText = activeNumber == null
? "New plate (empty) has no parts; plates with parts are listed on the right."
: plates.Contains(activePlate)
? $"Plate {activeNumber} of {displayCount}, numbered in cutting order:"
: $"Plate {activeNumber} has no parts; plates with parts are listed on the right.";
previewLabel.Text = previewText;
ShowSettings();
}
/// <summary>The settings the operator confirmed; meaningful after an OK result.</summary>
public CuttingParameters ConfirmedParameters => parameters;
/// <summary>The commit outcome of the last Apply, or null if Apply never ran.</summary>
public CuttingCommitResult CommitResult { get; private set; }
internal Task PlanningTask { get; private set; } = Task.CompletedTask;
internal CuttingPlanProposal Proposal => proposal;
internal bool IsPlanning => planning != null;
/// <summary>Test seam: runs on the worker, before planning, with the run's cancellation token.</summary>
internal Action<CancellationToken> BeforePlan { get; set; }
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;
var widest = split.Width - split.Panel2MinSize - split.SplitterWidth;
if (widest > split.Panel1MinSize)
split.SplitterDistance = System.Math.Clamp(split.Width * 3 / 5, split.Panel1MinSize, widest);
}
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
StartPlanning();
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (planning != null)
{
// Keep the editor disabled until the worker has stopped; then close for real.
e.Cancel = true;
closeRequested = true;
planning.Cancel();
statusLabel.Text = "Cancelling…";
cancelButton.Enabled = false;
}
base.OnFormClosing(e);
}
private bool TryCheckCanChange(out string reason)
{
reason = null;
if (isOperationBusy?.Invoke() == true || plateView.IsFillInProgress)
reason = "Wait for the current nesting or plate action to finish, then replan.";
else if (plateView.IsDisposed || !ReferenceEquals(plateView.Plate, activePlate))
reason = "The active plate changed. Close this dialog and try again.";
return reason == null;
}
private void StartPlanning()
{
if (planning != null || IsDisposed)
return; // One planning run at a time; inputs are disabled while it runs.
ClearProposal();
if (!TryCheckCanChange(out var reason))
{
ShowMessage(reason);
return;
}
CuttingPlanBatch batch;
try
{
batch = CuttingPlanBatch.Capture(plates, parameters, keepOrderCheckBox.Checked, plateNumbers);
}
catch (Exception ex)
{
// Capture runs inside the dialog's message loop: report a failure, never crash the editor.
ShowMessage($"Unable to start planning: {ex.Message}");
return;
}
var source = new CancellationTokenSource();
var token = source.Token;
planning = source;
var run = ++generation;
SetPlanning(true);
summaryBox.Text = string.Empty;
statusLabel.Text = "Planning…";
var progress = new PostingProgress(uiContext, value =>
{
if (run == generation && ReferenceEquals(planning, source) && !IsDisposed)
statusLabel.Text = ProgressText(value);
});
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
PlanningTask = completion.Task;
var beforePlan = BeforePlan;
Task.Run(() =>
{
beforePlan?.Invoke(token);
return batch.Plan(progress, token);
}).ContinueWith(
work => uiContext.Post(_ => FinishPlanning(work, source, run, completion), null),
CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default);
}
// Runs on the dialog's thread once the worker has stopped.
private void FinishPlanning(Task<CuttingPlanProposal> work, CancellationTokenSource source, int run,
TaskCompletionSource completion)
{
try
{
if (ReferenceEquals(planning, source))
planning = null;
source.Dispose();
if (run != generation || IsDisposed)
return;
SetPlanning(false);
if (closeRequested)
{
Close();
return;
}
if (work.IsFaulted)
ShowMessage($"Planning failed: {work.Exception.GetBaseException().Message}");
else if (work.IsCanceled)
ShowMessage("Planning was cancelled. Nothing has changed.");
else
ShowProposal(work.Result);
}
catch (Exception ex) when (!IsDisposed)
{
// A plan that cannot be presented must not leave a stale preview or an enabled Apply.
ShowMessage($"Unable to show the plan: {ex.Message}");
}
finally
{
completion.TrySetResult();
}
}
private void ShowProposal(CuttingPlanProposal result)
{
proposal = result;
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."
: result.RequiresWarningAcceptance ? "Unverified plan: review and accept the warnings to apply."
: "Nothing can be applied.";
applyButton.Enabled = result.CanApply;
var index = Array.IndexOf(plates, activePlate);
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)
{
previewLabel.Text = $"Plate {plateNumbers[index]}: no current, usable preview. "
+ "Part numbers refer to the editor; no location can be certified here.";
return;
}
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;
preview.Visible = false;
previewLabel.Text = previewText;
}
private void ShowMessage(string message)
{
ClearProposal();
statusLabel.Text = string.Empty;
summaryBox.Text = message;
}
private void SetPlanning(bool running)
{
settingsButton.Enabled = !running;
keepOrderCheckBox.Enabled = !running;
acceptWarningsCheckBox.Enabled = !running;
planButton.Enabled = !running;
if (running)
applyButton.Enabled = false;
}
private static string ProgressText(CuttingPlanProgress progress)
{
var plate = progress.PlateCount == 1
? $"plate {progress.PlateNumber}"
: $"plate {progress.PlateNumber} ({progress.PlateIndex + 1} of {progress.PlateCount})";
return progress.Phase switch
{
CuttingPlanPhase.CheckingOverlap => $"Checking {plate} for overlapping parts…",
CuttingPlanPhase.Reordering => $"Planning {plate}: searching for a part order…",
_ => $"Planning {plate} with the current part order…",
};
}
private void ShowSettings()
{
var tabs = parameters.TabsEnabled ? "on" : "off";
settingsLabel.Text = $"Lead-ins: outside {Describe(parameters.ExternalLeadIn)}, holes "
+ $"{Describe(parameters.InternalLeadIn)}, arcs and circles {Describe(parameters.ArcCircleLeadIn)}. "
+ $"Tabs {tabs}.";
}
private static string Describe(LeadIn leadIn)
{
if (leadIn == null || leadIn is NoLeadIn)
return "none";
var name = leadIn.GetType().Name;
if (name.EndsWith("LeadIn", StringComparison.Ordinal))
name = name[..^"LeadIn".Length];
return Regex.Replace(name, "(?<=[a-z])(?=[A-Z])", " ").ToLowerInvariant();
}
private void SettingsButton_Click(object sender, EventArgs e)
{
using var dialog = new CuttingParametersDialog();
dialog.LoadParameters(parameters);
if (dialog.ShowDialog(this) != DialogResult.OK)
return;
parameters = dialog.GetParameters();
ShowSettings();
StartPlanning();
}
private void KeepOrderCheckBox_CheckedChanged(object sender, EventArgs e) => StartPlanning();
private void PlanButton_Click(object sender, EventArgs e) => StartPlanning();
private sealed class PostingProgress(SynchronizationContext context, Action<CuttingPlanProgress> handler)
: IProgress<CuttingPlanProgress>
{
public void Report(CuttingPlanProgress value) => context.Post(_ => handler(value), null);
}
private bool CanApplyProposal => proposal?.CanApply == true
|| proposal?.CanApplyWithWarnings == true && acceptWarningsCheckBox.Checked;
private void AcceptWarningsCheckBox_CheckedChanged(object sender, EventArgs e) =>
applyButton.Enabled = planning == null && CanApplyProposal;
private void ApplyButton_Click(object sender, EventArgs e)
{
if (planning != null || !CanApplyProposal)
return;
if (!TryCheckCanChange(out var reason))
{
ShowMessage(reason);
return;
}
var commit = proposal.Apply(acceptWarningsCheckBox.Checked);
CommitResult = commit;
switch (commit.Status)
{
case CuttingCommitStatus.Applied:
DialogResult = DialogResult.OK;
Close();
return;
case CuttingCommitStatus.Stale:
ShowMessage("The nest changed after planning, so nothing was applied. Replan to continue.");
return;
default:
ShowMessage($"Nothing was applied: {commit.Message ?? commit.Status.ToString()}");
return;
}
}
}
-25
View File
@@ -1009,31 +1009,6 @@ namespace OpenNest.Forms
CalculateCurrentPlateCutTime();
}
/// <summary>
/// Opens the cutting planner for the active plate or every plate. The planner applies its
/// plan itself, all or nothing; on success the confirmed settings become the saved defaults.
/// </summary>
public bool PlanCutting(bool allPlates, Func<bool> isOperationBusy, IWin32Window owner)
{
if (CuttingInspection != null || PlateView?.Plate == null || Nest == null)
return false;
// The form plans with its own copy; the plate's live settings stay plate state.
var parameters = LoadOrDefaultParameters(PlateView.Plate.CuttingParameters);
using var form = new CuttingPlanForm(PlateView, Nest, allPlates, parameters, isOperationBusy);
if (form.ShowDialog(owner) != DialogResult.OK)
return false;
SaveCuttingParameters(form.ConfirmedParameters);
UpdatePlateList();
if (form.CommitResult?.RefreshErrors.Count > 0)
MessageBox.Show(owner,
"The cutting plan was applied, but part of the display did not refresh: "
+ form.CommitResult.RefreshErrors[0].Message,
"Plan Cutting", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return true;
}
public void AssignLeadIns_Click(object sender, EventArgs e)
{
if (PlateView?.Plate == null)
+18 -5
View File
@@ -9,6 +9,7 @@ using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Actions;
using OpenNest.Collections;
using OpenNest.Controls;
using OpenNest.Data;
using OpenNest.Diagnostics;
using OpenNest.Engine;
@@ -1925,6 +1926,21 @@ namespace OpenNest.Forms
private void NestPlanCutting_Click(object sender, EventArgs e) => ShowCuttingPlan(allPlates: true);
internal bool IsCuttingPlanBusy(PlateView[] views) => nestingInProgress
|| Application.OpenForms.OfType<NestProgressForm>().Any()
|| views.Any(v => v.IsFillInProgress || v.Actions.CurrentAction?.IsBusy() == true);
// Native tests capture this notice without opening a modal dialog in session zero.
internal System.Action<string, string> CuttingPlanNotice { get; set; }
private void ShowCuttingPlanNotice(string message, string caption)
{
if (CuttingPlanNotice != null)
CuttingPlanNotice(message, caption);
else
MessageBox.Show(this, message, caption, MessageBoxButtons.OK, MessageBoxIcon.Information);
}
private void ShowCuttingPlan(bool allPlates)
{
if (MdiChildren.OfType<EditNestForm>().Any(form => form.CuttingInspection != null && !ReferenceEquals(form, activeForm)))
@@ -1938,12 +1954,9 @@ namespace OpenNest.Forms
.Where(form => ReferenceEquals(form.Nest, nest))
.Select(form => form.PlateView).ToArray();
// A closed progress window can still have a fill awaiting completion/commit.
bool IsBusy() => nestingInProgress || Application.OpenForms.OfType<NestProgressForm>().Any()
|| views.Any(v => v.IsFillInProgress || v.Actions.CurrentAction?.IsBusy() == true);
if (IsBusy())
if (IsCuttingPlanBusy(views))
{
MessageBox.Show(this, "Finish or cancel the current nesting or plate action first.",
"Plan Cutting", MessageBoxButtons.OK, MessageBoxIcon.Information);
ShowCuttingPlanNotice("Finish or cancel the current nesting or plate action first.", "Plan Cutting");
return;
}
-326
View File
@@ -1,326 +0,0 @@
using System;
using System.Drawing;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Diagnostics;
namespace OpenNest.Forms;
/// <summary>Modeless inspection; the editor scope blocks edits while leaving canvas navigation available.</summary>
public sealed class ValidateNestForm : Form
{
private readonly Nest nest;
private readonly ValidationInspectionScope inspection;
private readonly Func<Nest, CancellationToken, Task<PostVerificationReport>> analyze;
private readonly CancellationTokenSource cancellation = new();
private readonly Label summaryLabel;
private readonly TextBox reportBox;
private readonly DataGridView warningGrid;
private readonly EditNestForm editor;
private readonly int originalPlate;
private bool populating;
private bool editorRestored;
private readonly (float Scale, PointF Origin) originalViewport;
private readonly OverlapDisplayMode originalOverlapDisplay;
private readonly Button closeButton;
private bool started;
private bool analyzing;
private bool closeRequested;
private bool disposedResources;
public ValidateNestForm(Nest nest)
: this(nest, (value, token) => Task.Run(() => PostVerificationAnalyzer.Analyze(value, token), token))
{ }
public ValidateNestForm(EditNestForm editor)
: this(editor, (value, token) => Task.Run(() => PostVerificationAnalyzer.Analyze(value, token), token))
{ }
internal ValidateNestForm(EditNestForm editor, Func<Nest, CancellationToken, Task<PostVerificationReport>> analyze)
: this(editor.Nest, analyze)
{
this.editor = editor;
inspection = new ValidationInspectionScope(editor, this);
originalPlate = editor.PlateManager.CurrentIndex;
originalViewport = editor.PlateView.CaptureViewport();
originalOverlapDisplay = editor.PlateView.OverlapDisplay;
editor.PlateView.OverlapDisplay = OverlapDisplayMode.Off;
StartPosition = FormStartPosition.Manual;
MinimumSize = new Size(480, 440);
ClientSize = new Size(560, 650);
}
internal ValidateNestForm(Nest nest, Func<Nest, CancellationToken, Task<PostVerificationReport>> analyze)
{
this.nest = nest ?? throw new ArgumentNullException(nameof(nest));
this.analyze = analyze ?? throw new ArgumentNullException(nameof(analyze));
Text = "Validate Nest";
Name = nameof(ValidateNestForm);
StartPosition = FormStartPosition.CenterParent;
ClientSize = new Size(850, 560);
MinimumSize = new Size(700, 440);
ShowInTaskbar = true;
MinimizeBox = true;
AutoScaleMode = AutoScaleMode.Font;
var layout = new TableLayoutPanel
{
Dock = DockStyle.Fill,
Padding = new Padding(12),
ColumnCount = 1,
RowCount = 4,
};
layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
layout.RowStyles.Add(new RowStyle(SizeType.AutoSize));
summaryLabel = new Label
{
Name = "summaryLabel",
AutoSize = true,
Dock = DockStyle.Fill,
Text = "Checking all plates: overlaps, missing lead-ins, and rapid crossings...",
Margin = new Padding(3, 3, 3, 10),
};
var scopeLabel = new Label
{
AutoSize = true,
Dock = DockStyle.Fill,
Text = "Select an issue to locate it in the main plate view. Its highlight pulses; unrelated parts appear gray.\r\n"
+ "Minimum spacing, plate boundaries, and final post-processor output are not checked.",
Margin = new Padding(3, 3, 3, 10),
};
reportBox = new TextBox
{
Name = "reportBox",
Dock = DockStyle.Fill,
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Vertical,
WordWrap = true,
MaxLength = 0,
AccessibleName = "Nest validation findings",
Text = "Validation is running...",
};
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,
AccessibleName = "Nest validation findings",
};
foreach (var title in new[] { "Plate", "Part", "With", "Issue", "Finding" })
warningGrid.Columns.Add(new DataGridViewTextBoxColumn
{
HeaderText = title,
Width = title == "Issue" ? 115 : 48,
AutoSizeMode = title == "Finding" ? DataGridViewAutoSizeColumnMode.Fill : DataGridViewAutoSizeColumnMode.None,
SortMode = DataGridViewColumnSortMode.NotSortable,
});
warningGrid.CurrentCellChanged += (_, _) => ShowSelectedFinding();
warningGrid.CellClick += (_, _) => ShowSelectedFinding();
var findings = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2, ColumnCount = 1 };
findings.RowStyles.Add(new RowStyle(SizeType.Percent, 65));
findings.RowStyles.Add(new RowStyle(SizeType.Percent, 35));
findings.Controls.Add(warningGrid, 0, 0);
findings.Controls.Add(reportBox, 0, 1);
closeButton = new Button
{
Name = "closeButton",
Text = "Cancel",
AutoSize = true,
DialogResult = DialogResult.Cancel,
};
closeButton.Click += (_, _) => Close();
var buttons = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
AutoSize = true,
FlowDirection = FlowDirection.RightToLeft,
};
buttons.Controls.Add(closeButton);
layout.Controls.Add(summaryLabel, 0, 0);
layout.Controls.Add(scopeLabel, 0, 1);
layout.Controls.Add(findings, 0, 2);
layout.Controls.Add(buttons, 0, 3);
Controls.Add(layout);
AcceptButton = closeButton;
CancelButton = closeButton;
}
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
if (editor == null)
return;
var area = Screen.FromControl(editor).WorkingArea;
Bounds = new Rectangle(area.Right - Width, area.Top, Width, System.Math.Min(Height, area.Height));
}
private void ShowSelectedFinding()
{
if (editorRestored || populating || editor == null || editor.IsDisposed)
return;
var view = editor.PlateView;
view.ClearValidationFinding();
if (warningGrid.CurrentRow?.Tag is not PostVerificationFinding finding
|| finding.PlateNumber < 1 || finding.PlateNumber > 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);
// Keep the marker in the portion of the main canvas left of this floating inspector.
var screen = view.RectangleToScreen(view.ClientRectangle);
var available = System.Math.Clamp(Left - screen.Left, 0, view.Width);
if (available > 100)
view.CenterValidationPoint(point, new Point(available / 2, view.Height / 2));
}
else
view.ZoomToFit();
view.ShowValidationFinding(finding with { Location = location });
ShowFindingDetails(finding);
}
private void ShowFindingDetails(PostVerificationFinding finding)
{
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. For travel along an edge, they mark the contact span's endpoints. 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.",
};
reportBox.Text = $"Plate {finding.PlateNumber}, part {finding.PartNumber}: {finding.Message}"
+ Environment.NewLine + Environment.NewLine + hint;
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
RestoreEditor();
base.OnFormClosed(e);
}
private void RestoreEditor()
{
if (editorRestored || editor == null || editor.IsDisposed)
return;
editorRestored = true;
inspection?.Dispose();
editor.PlateView.ClearValidationFinding();
if (editor.PlateManager.CurrentIndex != originalPlate)
editor.PlateManager.LoadAt(originalPlate);
editor.PlateView.RestoreViewport(originalViewport);
editor.PlateView.OverlapDisplay = originalOverlapDisplay;
}
protected override async void OnShown(EventArgs e)
{
base.OnShown(e);
await ValidateAsync();
}
internal async Task ValidateAsync()
{
if (started)
return;
started = true;
analyzing = true;
try
{
var report = await analyze(nest, cancellation.Token);
if (closeRequested || IsDisposed)
return;
populating = true;
foreach (var finding in report.Findings)
{
var index = warningGrid.Rows.Add(finding.PlateNumber, finding.PartNumber, finding.OtherPartNumber,
finding.Kind, finding.Message);
warningGrid.Rows[index].Tag = finding;
}
populating = false;
ShowSelectedFinding();
reportBox.Text = report.ToDisplayText("Nest validation");
if (editor != null && warningGrid.CurrentRow?.Tag is PostVerificationFinding selected)
ShowFindingDetails(selected);
reportBox.SelectionStart = 0;
reportBox.SelectionLength = 0;
summaryLabel.Text = report.Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete)
? "Validation incomplete. Review the findings; this nest has not passed all checks."
: report.HasWarnings ? "Warnings found. Review the findings below."
: "Validation complete. No warnings found by these checks.";
}
catch (OperationCanceledException) when (cancellation.IsCancellationRequested)
{
closeRequested = true;
}
catch (Exception ex)
{
if (!IsDisposed)
{
summaryLabel.Text = "Validation failed. This nest has not passed the checks.";
reportBox.Text = "Validation could not finish. Close this window and try again."
+ Environment.NewLine + Environment.NewLine + ex.Message;
}
}
finally
{
analyzing = false;
if (!IsDisposed)
{
closeButton.Text = "Close";
if (closeRequested)
{
DialogResult = DialogResult.Cancel;
Close();
}
}
}
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (analyzing)
{
closeRequested = true;
cancellation.Cancel();
closeButton.Enabled = false;
summaryLabel.Text = "Canceling validation...";
e.Cancel = true;
}
base.OnFormClosing(e);
}
protected override void Dispose(bool disposing)
{
if (disposing && !disposedResources)
{
disposedResources = true;
RestoreEditor();
inspection?.Dispose();
cancellation.Cancel();
cancellation.Dispose();
}
base.Dispose(disposing);
}
}
@@ -19,11 +19,6 @@ internal sealed class ValidationInspectionScope : IDisposable
private Form closingHost;
private readonly bool previousInspection;
public ValidationInspectionScope(EditNestForm editor, Form inspector)
: this(editor, inspector, null, inspector.Close)
{
}
public ValidationInspectionScope(EditNestForm editor, Control inspector, Control header, Action close)
{
view = editor.PlateView;
+2
View File
@@ -361,6 +361,8 @@ unlocking inspection. Planning is not live-nest validation or posting consent. T
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 former standalone planning dialog is retired; only the docked Plan/Validate
panel is wired in the editor. Non-editor callers still use the shared analyzer.
- 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
+3 -1
View File
@@ -42,7 +42,9 @@ cancellation and waits for the worker to stop before restoring editing controls.
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.
is refused; close the panel and use the fresh posting review. The former floating
validator window is retired; non-editor checks remain available through the
shared `PostVerificationAnalyzer` without an editor UI dependency.
## Posting workflow
+1 -1
View File
@@ -36,7 +36,7 @@ removing or renaming required coverage requires an intentional manifest change.
The whole suites still run, not just this manifest's subset.
The areas are BOM import, Nest Info material preservation, saved-nest browsing,
nesting/commit policy, PDF export, the cutting dialog, automatic cutoffs,
nesting/commit policy, PDF export, docked cutting planning and validation, automatic cutoffs,
overlap/posting consent, UI lifetime, import/export failures, and MCP processes.
The manifest is public test coverage only. Private tracker IDs, plans, customer
data, and acceptance notes stay out of the repository and CI artifacts.
+25 -9
View File
@@ -353,7 +353,7 @@
},
{
"id": "cutting-planner",
"title": "Current/all-plate cutting dialog",
"title": "Docked cutting planning and live validation",
"required_tests": [
{
"project": "OpenNest.WinForms.Tests",
@@ -372,42 +372,58 @@
},
{
"project": "OpenNest.WinForms.Tests",
"method": "OpenNest.WinForms.Tests.Forms.CuttingPlanFormTests.AllPlates_SkipsEmptyPlatesAndRefusesTheBatchWhenOnePlateIsBlocked",
"method": "OpenNest.WinForms.Tests.Forms.CuttingInspectionPanelTests.AllPlatesNavigatesReadyAndBlockedWithoutPartialApply",
"minimum_cases": 1
},
{
"project": "OpenNest.WinForms.Tests",
"method": "OpenNest.WinForms.Tests.Forms.CuttingPlanFormTests.ApplyAfterALiveEdit_ChangesNothingAndReplanRecovers",
"method": "OpenNest.WinForms.Tests.Forms.CuttingInspectionPanelTests.StaleApplyClearsPreviewAndAcceptanceWithoutChangingPrograms",
"minimum_cases": 1
},
{
"project": "OpenNest.WinForms.Tests",
"method": "OpenNest.WinForms.Tests.Forms.CuttingPlanFormTests.BlockedPlate_KeepsApplyDisabledAndShowsTheCurrentParts",
"method": "OpenNest.WinForms.Tests.Forms.CuttingInspectionPanelTests.WarningAcceptanceResetsOnReplanAndSettingsOrderChange",
"minimum_cases": 1
},
{
"project": "OpenNest.WinForms.Tests",
"method": "OpenNest.WinForms.Tests.Forms.CuttingPlanFormTests.BusyEditor_DoesNotStartPlanningUntilItIsFree",
"method": "OpenNest.WinForms.Tests.Forms.CuttingInspectionPanelTests.CloseDuringWorkerRetainsPanelAndInspectionLockUntilCompletion",
"minimum_cases": 1
},
{
"project": "OpenNest.WinForms.Tests",
"method": "OpenNest.WinForms.Tests.Forms.CuttingPlanFormTests.ClosingWhilePlanning_CancelsAndWaitsForTheWorker",
"method": "OpenNest.WinForms.Tests.Forms.CuttingInspectionPanelTests.ConstructorKeepsItsOwnCopyOfSettings",
"minimum_cases": 1
},
{
"project": "OpenNest.WinForms.Tests",
"method": "OpenNest.WinForms.Tests.Forms.CuttingPlanFormTests.Constructor_PlansWithItsOwnCopyOfTheSettings",
"method": "OpenNest.WinForms.Tests.Forms.CuttingInspectionPanelTests.BusyMenuRefusesPlanningAndFreeMenuOpensDockedPanel",
"minimum_cases": 1
},
{
"project": "OpenNest.WinForms.Tests",
"method": "OpenNest.WinForms.Tests.Forms.CuttingPlanFormTests.PlanThenApply_InstallsThePlanAndRedrawsTheEditorInPlateOrder",
"method": "OpenNest.WinForms.Tests.Forms.CuttingInspectionPanelTests.ReadyApplyInstallsExactPreviewAndRefreshesLiveView",
"minimum_cases": 1
},
{
"project": "OpenNest.WinForms.Tests",
"method": "OpenNest.WinForms.Tests.Forms.CuttingValidationPanelTests.ValidateMenuUsesSharedPanelAndRealAllPlateFindingsRestoreOriginalView",
"minimum_cases": 1
},
{
"project": "OpenNest.WinForms.Tests",
"method": "OpenNest.WinForms.Tests.Forms.CuttingValidationPanelTests.ExternalCloseJoinsValidationBeforeReleasingMdiGuard",
"minimum_cases": 3
},
{
"project": "OpenNest.WinForms.Tests",
"method": "OpenNest.WinForms.Tests.Forms.CuttingValidationPanelTests.ModeSwitchJoinsBothWorkersAndDiscardsOldResultsWithoutUnlocking",
"minimum_cases": 1
}
],
"remaining_acceptance": [
"Selected-part side-panel migration and legacy automatic-path retirement are separate unfinished work.",
"Native operator check of docked panel readability, multi-plate navigation, Escape/X/Cancel, and final post review; automated form tests are not visual acceptance.",
"Selected-part Auto Assign migration and legacy automatic-algorithm retirement are separate work.",
"Actual job and NC/controller safety are not certified."
]
},