diff --git a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
index f975009..0999143 100644
--- a/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
+++ b/OpenNest.Core/CNC/CuttingPlanning/ReleasedContourState.cs
@@ -68,7 +68,8 @@ public sealed class ReleasedContourState
if (PostVerificationGeometry.Crosses(start, move.End, obstacle.Curves, token))
findings.Add(new(PostVerificationKind.RapidCrossing, plate, part, obstacle.Part,
$"Direct XY rapid crosses or touches completed untabbed contour {obstacle.Contour} " +
- $"of part {obstacle.Part}."));
+ $"of part {obstacle.Part}.")
+ { Location = (start + move.End) * 0.5, RapidStart = start, RapidEnd = move.End });
}
}
else if (move.Layer == LayerType.Leadin)
@@ -94,7 +95,8 @@ public sealed class ReleasedContourState
contourNumber++;
if (!cutoff && !hasLead)
findings.Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
- $"Cutting contour {contourNumber} has no nonzero placed lead-in motion."));
+ $"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")
+ { Location = move.Curve.Start });
hasLead = false;
// A rapid can pause/reposition without leaving any material gap.
// Retain already-cut fragments, but do not turn them into obstacles
diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
index aa5097f..4110b56 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
@@ -113,7 +113,8 @@ public static class PostVerificationAnalyzer
PlateOverlapAnalyzer.Capture(materialParts, cancellationToken), cancellationToken);
foreach (var pair in overlap.Pairs)
findings.Add(new(PostVerificationKind.Overlap, plateNumber, indices[pair.PartAId],
- indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted)."));
+ indices[pair.PartBId], "Clean drawing material overlaps (holes subtracted).")
+ { Location = pair.Centroid, Overlap = pair });
foreach (var issue in overlap.Issues)
findings.Add(new(PostVerificationKind.Incomplete, plateNumber, indices[issue.PartAId],
issue.PartBId is { } other ? indices[other] : null, "Overlap check: " + issue.Message));
diff --git a/OpenNest.Core/Diagnostics/PostVerificationReport.cs b/OpenNest.Core/Diagnostics/PostVerificationReport.cs
index 835176f..af84d94 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationReport.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationReport.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
+using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
@@ -15,7 +16,13 @@ public enum PostVerificationKind
/// Plate and part numbers are one-based; plate zero denotes a whole-post limitation.
public sealed record PostVerificationFinding(PostVerificationKind Kind, int PlateNumber,
- int? PartNumber, int? OtherPartNumber, string Message);
+ int? PartNumber, int? OtherPartNumber, string Message)
+{
+ public Vector? Location { get; init; }
+ public Vector? RapidStart { get; init; }
+ public Vector? RapidEnd { get; init; }
+ public PlateOverlapPair Overlap { get; init; }
+}
/// Owned, immutable findings. Consent is evaluated afresh, never stored.
public sealed class PostVerificationReport
@@ -29,10 +36,12 @@ public sealed class PostVerificationReport
public bool HasWarnings => Findings.Count != 0;
public bool CanPost(bool risksAcknowledged) => !HasWarnings || risksAcknowledged;
- public string ToDisplayText()
+ public string ToDisplayText() => ToDisplayText("Pre-post verification");
+
+ public string ToDisplayText(string title)
{
var text = new StringBuilder();
- text.AppendLine("Pre-post verification");
+ text.AppendLine(title);
var incomplete = Findings.Any(finding => finding.Kind == PostVerificationKind.Incomplete);
Summary(PostVerificationKind.Overlap, "Overlap");
Summary(PostVerificationKind.MissingLeadIn, "Missing lead-ins");
diff --git a/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs
index a14757e..1906599 100644
--- a/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs
+++ b/OpenNest.Tests/Diagnostics/PostVerificationAnalyzerTests.cs
@@ -36,6 +36,9 @@ public class PostVerificationAnalyzerTests
var report = Analyze(Cutoff(30, 30), a, Scribe(40, 40), b);
var overlap = Assert.Single(Find(report, PostVerificationKind.Overlap));
Assert.Equal((1, 2, 4), (overlap.PlateNumber, overlap.PartNumber, overlap.OtherPartNumber));
+ Assert.NotNull(overlap.Overlap);
+ Assert.Equal(new Vector(1.5, 1.5), overlap.Location);
+ Assert.Equal(1, overlap.Overlap.Area, 6);
Assert.True(report.HasWarnings);
Assert.False(report.CanPost(false));
Assert.True(report.CanPost(true));
@@ -58,7 +61,8 @@ public class PostVerificationAnalyzerTests
{
var part = Rectangle(0, 0, 4);
part.HasManualLeadIns = true;
- Assert.Single(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
+ var finding = Assert.Single(Find(Analyze(part), PostVerificationKind.MissingLeadIn));
+ Assert.Equal(Vector.Zero, finding.Location);
}
[Fact]
@@ -105,8 +109,11 @@ public class PostVerificationAnalyzerTests
public void Rapid_ThroughPreviouslyCutPartWarns()
{
var report = Analyze(Rectangle(0, 0, 4), Scribe(-2, 2), Scribe(6, 2));
- Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f =>
- f.PartNumber == 3 && f.OtherPartNumber == 1);
+ var crossing = Assert.Single(Find(report, PostVerificationKind.RapidCrossing).Where(f =>
+ f.PartNumber == 3 && f.OtherPartNumber == 1));
+ Assert.NotNull(crossing.RapidStart);
+ Assert.NotNull(crossing.RapidEnd);
+ Assert.Equal((crossing.RapidStart.Value + crossing.RapidEnd.Value) * 0.5, crossing.Location);
}
[Fact]
@@ -169,8 +176,11 @@ public class PostVerificationAnalyzerTests
public void Rapid_CutoffTravelStillCrossesEarlierRealCuts()
{
var report = Analyze(Rectangle(0, 0, 4), Scribe(-2, 2), Cutoff(6, 2));
- Assert.Contains(Find(report, PostVerificationKind.RapidCrossing), f =>
- f.PartNumber == 3 && f.OtherPartNumber == 1);
+ var crossing = Assert.Single(Find(report, PostVerificationKind.RapidCrossing).Where(f =>
+ f.PartNumber == 3 && f.OtherPartNumber == 1));
+ Assert.NotNull(crossing.RapidStart);
+ Assert.NotNull(crossing.RapidEnd);
+ Assert.Equal((crossing.RapidStart.Value + crossing.RapidEnd.Value) * 0.5, crossing.Location);
}
[Theory]
diff --git a/OpenNest.WinForms.Tests/Forms/ValidateNestFormTests.cs b/OpenNest.WinForms.Tests/Forms/ValidateNestFormTests.cs
new file mode 100644
index 0000000..d0e063e
--- /dev/null
+++ b/OpenNest.WinForms.Tests/Forms/ValidateNestFormTests.cs
@@ -0,0 +1,274 @@
+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
+{
+#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(form, "warningGrid");
+ Assert.True(grid.ReadOnly);
+ Assert.Equal(DataGridViewSelectionMode.FullRowSelect, grid.SelectionMode);
+ var row = grid.Rows.Cast().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(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();
+ 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(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