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