Add interactive nest validation with animated issue highlights

This commit is contained in:
aj committed 2026-10-10 14:51:47 -04:00
1 parent 80c647bf07
commit bb108e9341
12 files changed
+959 -17

No files matched your search

@@ -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
@@ -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));
@@ -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
/// <summary>Plate and part numbers are one-based; plate zero denotes a whole-post limitation.</summary>
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; }
}
/// <summary>Owned, immutable findings. Consent is evaluated afresh, never stored.</summary>
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");
@@ -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]
@@ -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<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());
}
+59 -4
View File
@@ -25,6 +25,7 @@ namespace OpenNest.Controls
{
private readonly Timer redrawTimer;
private readonly OverlapOverlayController overlapOverlay;
private readonly ValidationOverlay validationOverlay;
private string status;
private Plate plate;
@@ -102,6 +103,7 @@ namespace OpenNest.Controls
public PlateView(ColorScheme colorScheme)
{
overlapOverlay = new OverlapOverlayController(this);
validationOverlay = new ValidationOverlay(this);
Plate = new Plate(60, 120);
origin = new PointF();
parts = new List<LayoutPart>();
@@ -151,6 +153,8 @@ namespace OpenNest.Controls
public ColorScheme ColorScheme { get; set; }
internal bool InspectionOnly { get; set; }
public bool AllowZoom { get; set; }
public bool AllowSelect { get; set; }
@@ -218,6 +222,7 @@ namespace OpenNest.Controls
selection.Clear();
}
validationOverlay.Clear();
plate = p;
plate.PartAdded += plate_PartAdded;
plate.PartRemoved += plate_PartRemoved;
@@ -231,6 +236,25 @@ namespace OpenNest.Controls
}
internal OverlapOverlayController OverlapOverlay => overlapOverlay;
internal ValidationOverlay ValidationOverlay => validationOverlay;
internal (float Scale, PointF Origin) CaptureViewport() => (ViewScale, origin);
internal void RestoreViewport((float Scale, PointF Origin) viewport)
{
ViewScale = viewport.Scale;
origin = viewport.Origin;
UpdateMatrix();
Invalidate();
}
internal void ShowValidationFinding(PostVerificationFinding finding) => validationOverlay.Show(finding);
internal void ClearValidationFinding() => validationOverlay.Clear();
internal void CenterValidationPoint(Vector location, Point target)
{
var current = PointWorldToControl(location);
origin.X += target.X - current.X;
origin.Y += target.Y - current.Y;
UpdateMatrix();
Invalidate();
}
public OverlapCheckStatus OverlapStatus => overlapOverlay.Status;
public PlateOverlapReport OverlapReport => overlapOverlay.Report;
public bool IsOverlapCheckRunning => overlapOverlay.IsRunning;
@@ -317,6 +341,8 @@ namespace OpenNest.Controls
protected override void OnDragEnter(DragEventArgs drgevent)
{
if (InspectionOnly)
return;
if (drgevent.Data.GetData(typeof(Drawing)) != null)
drgevent.Effect = DragDropEffects.Copy;
else if (FilesDropped != null && drgevent.Data.GetDataPresent(DataFormats.FileDrop))
@@ -325,6 +351,8 @@ namespace OpenNest.Controls
protected override void OnDragDrop(DragEventArgs drgevent)
{
if (InspectionOnly)
return;
if (drgevent.Data.GetDataPresent(DataFormats.FileDrop))
{
FilesDropped?.Invoke(this, (string[])drgevent.Data.GetData(DataFormats.FileDrop));
@@ -351,6 +379,9 @@ namespace OpenNest.Controls
if (e.Button == MouseButtons.Middle)
middleMouseDownPoint = e.Location;
if (InspectionOnly)
return;
if (e.Button == MouseButtons.Left && actionManager.CurrentAction is ActionSelect)
{
var hitCutOff = cutOffHandler.TryStartDrag(CurrentPoint, 5.0 / ViewScale);
@@ -373,6 +404,8 @@ namespace OpenNest.Controls
protected override void OnMouseUp(MouseEventArgs e)
{
if (InspectionOnly)
return;
if (e.Button == MouseButtons.Middle && SelectedParts.Count > 0)
{
var dx = e.X - middleMouseDownPoint.X;
@@ -396,11 +429,12 @@ namespace OpenNest.Controls
protected override void OnMouseWheel(MouseEventArgs e)
{
base.OnMouseWheel(e);
if (!InspectionOnly)
base.OnMouseWheel(e);
var multiplier = System.Math.Abs(e.Delta / 120);
if (SelectedParts.Count > 0 && ((ModifierKeys & Keys.Shift) == Keys.Shift))
if (!InspectionOnly && SelectedParts.Count > 0 && ((ModifierKeys & Keys.Shift) == Keys.Shift))
{
var increment =
(ModifierKeys & Keys.Control) == Keys.Control
@@ -456,6 +490,9 @@ namespace OpenNest.Controls
lastPoint = e.Location;
if (InspectionOnly)
return;
if (cutOffHandler.IsDragging && selection.SelectedCutOffs.Count > 0)
{
cutOffHandler.UpdateDrag(CurrentPoint, selection.SelectedCutOffs[0]);
@@ -484,14 +521,21 @@ namespace OpenNest.Controls
protected override void OnMouseDoubleClick(MouseEventArgs e)
{
base.OnMouseDoubleClick(e);
if (!InspectionOnly)
base.OnMouseDoubleClick(e);
if (e.Button == MouseButtons.Middle && SelectedParts.Count == 0)
if (e.Button == MouseButtons.Middle && (InspectionOnly || SelectedParts.Count == 0))
ZoomToFit();
}
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
if (InspectionOnly)
{
if (keyData == Keys.F)
ZoomToFit();
return true;
}
// Audited: DrawControl/PlateView, Action key handlers, EditNestForm,
// MainForm.ProcessCmdKey and menu shortcuts have no plain PgUp/PgDn
// binding. BestFitViewerForm owns these only in its separate dialog.
@@ -504,6 +548,12 @@ namespace OpenNest.Controls
protected override void OnKeyDown(KeyEventArgs e)
{
if (InspectionOnly)
{
if (e.KeyData == Keys.F)
ZoomToFit();
return;
}
switch (e.KeyCode)
{
case Keys.Delete:
@@ -529,6 +579,8 @@ namespace OpenNest.Controls
protected override bool ProcessDialogKey(Keys keyData)
{
if (InspectionOnly)
return true;
// Only handle TAB, RETURN, ESC, and ARROW KEYS here.
// All other keys can be handled in OnKeyDown method.
@@ -603,6 +655,7 @@ namespace OpenNest.Controls
renderer.DrawActiveWorkArea(e.Graphics);
renderer.DrawDebugRemnants(e.Graphics);
DrawOverlapOverlay(e.Graphics);
validationOverlay.Draw(e.Graphics);
base.OnPaint(e);
@@ -633,6 +686,7 @@ namespace OpenNest.Controls
{
ClearHover();
overlapOverlay.ReleaseHandle();
validationOverlay.Clear();
base.OnHandleDestroyed(e);
actionManager.Cleanup();
}
@@ -649,6 +703,7 @@ namespace OpenNest.Controls
plate.PartsReordered -= plate_PartsReordered;
}
overlapOverlay?.Dispose();
validationOverlay?.Dispose();
hoverTimer?.Dispose();
hoverTimer = null;
redrawTimer?.Dispose();
+117
View File
@@ -0,0 +1,117 @@
using System;
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Controls;
/// <summary>Display-only findings, valid while the inspector keeps editing disabled.</summary>
internal sealed class ValidationOverlay : IDisposable
{
private readonly PlateView view;
private readonly Timer timer;
private readonly Stopwatch elapsed = new();
private PostVerificationFinding finding;
private GraphicsPath areas;
private float scale;
public ValidationOverlay(PlateView view)
{
this.view = view;
timer = new Timer { Interval = 30 };
timer.Tick += (_, _) => view.Invalidate();
}
internal bool IsAnimating => timer.Enabled;
internal PostVerificationFinding Finding => finding;
public void Show(PostVerificationFinding value)
{
Clear();
finding = value;
var canAnimate = value?.Kind == PostVerificationKind.RapidCrossing
? value.RapidStart is { } start && IsFinite(start) && value.RapidEnd is { } end && IsFinite(end)
: value?.Location is { } point && IsFinite(point);
if (canAnimate)
{
elapsed.Restart();
timer.Start();
}
view.Invalidate();
}
private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
public void Clear()
{
timer.Stop();
elapsed.Reset();
finding = null;
areas?.Dispose();
areas = null;
view.Invalidate();
}
public void Draw(Graphics graphics)
{
if (finding == null)
return;
var state = graphics.Save();
try
{
graphics.SmoothingMode = SmoothingMode.AntiAlias;
if (finding.Overlap is { } overlap)
{
if (areas == null || scale != view.ViewScale)
{
areas?.Dispose();
areas = new GraphicsPath(FillMode.Winding);
foreach (var region in overlap.Regions)
areas.AddPolygon(region.Vertices.Select(view.PointWorldToGraph).ToArray());
scale = view.ViewScale;
}
using var brush = new SolidBrush(Color.FromArgb(100, 255, 0, 80));
graphics.FillPath(brush, areas);
}
var dpi = view.DeviceDpi / 96f;
if (finding.Kind == PostVerificationKind.RapidCrossing)
{
if (finding.RapidStart is { } start && IsFinite(start)
&& finding.RapidEnd is { } end && IsFinite(end))
{
var pulse = (1 + System.Math.Cos(elapsed.Elapsed.TotalSeconds * System.Math.PI * 2 / 1.2)) / 2;
using var rapid = new Pen(Color.FromArgb((int)(65 + 190 * pulse), Color.DarkOrange), 3 * dpi)
{ DashStyle = DashStyle.Dash };
graphics.DrawLine(rapid, view.PointWorldToGraph(start), view.PointWorldToGraph(end));
}
return;
}
if (finding.Location is not { } location || !double.IsFinite(location.X) || !double.IsFinite(location.Y))
return;
var center = view.PointWorldToGraph(location);
var phase = timer.Enabled ? (float)(elapsed.Elapsed.TotalSeconds % 0.8 / 0.8) : 0;
var radius = (12 + 24 * phase) * dpi;
var alpha = (int)(255 * (1 - phase));
using var halo = new Pen(Color.FromArgb(alpha, Color.White), 5 * dpi);
using var ring = new Pen(Color.FromArgb(alpha, Color.DeepPink), 3 * dpi);
graphics.DrawEllipse(halo, center.X - radius, center.Y - radius, radius * 2, radius * 2);
graphics.DrawEllipse(ring, center.X - radius, center.Y - radius, radius * 2, radius * 2);
graphics.DrawLine(ring, center.X - 5 * dpi, center.Y, center.X + 5 * dpi, center.Y);
graphics.DrawLine(ring, center.X, center.Y - 5 * dpi, center.X, center.Y + 5 * dpi);
}
finally
{
graphics.Restore(state);
}
}
public void Dispose()
{
timer.Dispose();
areas?.Dispose();
}
}
+11 -2
View File
@@ -1,4 +1,4 @@
namespace OpenNest.Forms
namespace OpenNest.Forms
{
partial class MainForm
{
@@ -95,6 +95,7 @@
runAutoNestToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
autoSequenceAllPlatesToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
mnuNestRemoveEmptyPlates = new System.Windows.Forms.ToolStripMenuItem();
mnuNestValidate = new System.Windows.Forms.ToolStripMenuItem();
mnuNestPost = new System.Windows.Forms.ToolStripMenuItem();
toolStripMenuItem19 = new System.Windows.Forms.ToolStripSeparator();
calculateCutTimeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
@@ -571,7 +572,7 @@
//
// mnuNest
//
mnuNest.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuNestEdit, mnuNestImportDrawing, mnuNestShapeLibrary, toolStripMenuItem7, mnuNestFirstPlate, mnuNestLastPlate, toolStripMenuItem6, mnuNestNextPlate, mnuNestPreviousPlate, toolStripMenuItem12, runAutoNestToolStripMenuItem, autoSequenceAllPlatesToolStripMenuItem, mnuNestRemoveEmptyPlates, mnuNestPost, toolStripMenuItem19, calculateCutTimeToolStripMenuItem, toolStripMenuItem22, mnuNestAutomaticCutOff, mnuNestPlanCutting, mnuNestAssignLeadIns, mnuNestRemoveLeadIns });
mnuNest.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuNestEdit, mnuNestImportDrawing, mnuNestShapeLibrary, toolStripMenuItem7, mnuNestFirstPlate, mnuNestLastPlate, toolStripMenuItem6, mnuNestNextPlate, mnuNestPreviousPlate, toolStripMenuItem12, runAutoNestToolStripMenuItem, autoSequenceAllPlatesToolStripMenuItem, mnuNestRemoveEmptyPlates, mnuNestValidate, mnuNestPost, toolStripMenuItem19, calculateCutTimeToolStripMenuItem, toolStripMenuItem22, mnuNestAutomaticCutOff, mnuNestPlanCutting, mnuNestAssignLeadIns, mnuNestRemoveLeadIns });
mnuNest.Name = "mnuNest";
mnuNest.Size = new System.Drawing.Size(43, 20);
mnuNest.Text = "&Nest";
@@ -668,6 +669,13 @@
mnuNestRemoveEmptyPlates.Text = "Remove Empty Plates";
mnuNestRemoveEmptyPlates.Click += RemoveEmptyPlates_Click;
//
// mnuNestValidate
//
mnuNestValidate.Name = "mnuNestValidate";
mnuNestValidate.Size = new System.Drawing.Size(205, 22);
mnuNestValidate.Text = "Validate Nest...";
mnuNestValidate.Click += ValidateNest_Click;
//
// mnuNestPost
//
mnuNestPost.Name = "mnuNestPost";
@@ -1420,6 +1428,7 @@
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem23;
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem24;
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem12;
private System.Windows.Forms.ToolStripMenuItem mnuNestValidate;
private System.Windows.Forms.ToolStripMenuItem mnuNestPost;
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem13;
private System.Windows.Forms.ToolStripMenuItem mnuPlateViewInCad;
+29
View File
@@ -27,6 +27,7 @@ namespace OpenNest.Forms
public partial class MainForm : Form
{
private EditNestForm activeForm;
private ValidateNestForm validationForm;
private bool clickUpdateLocation;
private bool nestingInProgress;
private CancellationTokenSource nestingCts;
@@ -1553,6 +1554,34 @@ namespace OpenNest.Forms
activeForm.AutoSequenceAllPlates();
}
private void ValidateNest_Click(object sender, EventArgs e)
{
var editForm = activeForm;
if (editForm == null)
return;
var views = MdiChildren.OfType<EditNestForm>()
.Where(form => ReferenceEquals(form.Nest, editForm.Nest))
.Select(form => form.PlateView).ToArray();
if (nestingInProgress || Application.OpenForms.OfType<NestProgressForm>().Any()
|| views.Any(view => view.IsFillInProgress || view.Actions.CurrentAction?.IsBusy() == true))
{
MessageBox.Show(this, "Finish or cancel the current nesting or plate action before validating.",
"Validate Nest", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
foreach (var view in views)
view.SetAction(typeof(ActionSelect));
if (validationForm is { IsDisposed: false })
{
validationForm.Activate();
return;
}
validationForm = new ValidateNestForm(editForm);
validationForm.Show(this);
}
private void PostProcessor_Click(object sender, EventArgs e)
{
var menuItem = (ToolStripMenuItem)sender;
+324
View File
@@ -0,0 +1,324 @@
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 while selected.\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 => "The pulsing dashed orange line is the offending rapid segment; it does not mark an exact contact point.",
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);
}
}
@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Forms;
using OpenNest.Controls;
namespace OpenNest.Forms;
/// <summary>Keep live validation inputs stable while allowing navigation of the main canvas.</summary>
internal sealed class ValidationInspectionScope : IDisposable
{
private readonly List<(Control Control, bool Enabled)> controls = new();
private readonly List<(Form Form, bool AllowDrop)> hosts = new();
private bool disposed;
private readonly PlateView view;
private readonly Form inspector;
private readonly bool previousInspection;
public ValidationInspectionScope(EditNestForm editor, Form inspector)
{
view = editor.PlateView;
this.inspector = inspector;
previousInspection = view.InspectionOnly;
view.InspectionOnly = true;
var root = editor.MdiParent ?? editor;
DisableExceptCanvas(root);
hosts.Add((root, root.AllowDrop));
if (!ReferenceEquals(root, editor))
hosts.Add((editor, editor.AllowDrop));
foreach (var host in hosts)
{
host.Form.AllowDrop = false;
host.Form.FormClosing += HostClosing;
}
}
private void DisableExceptCanvas(Control parent)
{
foreach (var child in parent.Controls.Cast<Control>())
{
if (ReferenceEquals(child, view))
continue;
if (child.Contains(view))
DisableExceptCanvas(child);
else
{
controls.Add((child, child.Enabled));
child.Enabled = false;
}
}
}
private void HostClosing(object sender, FormClosingEventArgs e)
{
// A worker may still read the nest. Finish closing the inspector first.
e.Cancel = true;
inspector.Close();
}
public void Dispose()
{
if (disposed)
return;
disposed = true;
foreach (var host in hosts)
{
host.Form.FormClosing -= HostClosing;
if (!host.Form.IsDisposed)
host.Form.AllowDrop = host.AllowDrop;
}
foreach (var saved in controls.Where(saved => !saved.Control.IsDisposed))
saved.Control.Enabled = saved.Enabled;
if (!view.IsDisposed)
view.InspectionOnly = previousInspection;
}
}
+36
View File
@@ -1,5 +1,41 @@
# Verify a nest before posting
## Standalone desktop validation
Choose **Nest > Validate Nest...** to inspect all plates without running the cut
planner or selecting a post processor. The read-only **Validate Nest** window
checks overlapping material, missing lead-ins, and rapid crossings in the existing
placed programs and cutting order. Findings identify plate and part sequence
numbers in a read-only grid like the cut planner; incomplete or failed checks are
never reported as clear. Select a row to switch the main plate view to that plate
and focus the finding. The inspector opens at the right of the screen and can be
moved or resized. The details below the grid explain the selected warning.
Overlaps use the same magenta material shading as **View > Overlap Check**. A
DPI-scaled ring expands and fades continuously while the finding is selected;
both its color and contrasting halo fade as it grows. For overlaps the ring marks the area centroid (possibly between
separate patches); for missing lead-ins it marks the contour start. Rapid warnings
pulse the offending orange dashed segment continuously, without a ring or an
exact contact-point marker. Incomplete findings use the part center when available;
findings without a usable location do not invent a marker. Closing the inspector
clears the highlight and restores the original plate, zoom, and overlap display mode.
Validation does not change placements, programs, lead-ins, or cutting order, save
the nest, or write CNC output. Minimum spacing and plate boundaries are not checked.
It does not verify a post processor's final output. Posting still runs its own
fresh verification and consent flow.
Finish or cancel active nesting/fill operations before validating. The window is
a normal top-level, modeless form. Editing controls and shortcuts stay disabled
while it is open, but the main plate view remains available for wheel zoom,
middle-button pan, and **F** or middle-button double-click to fit. Close the
inspector to resume editing. Cancel, Escape, or closing the window requests
cancellation and waits for the worker to stop before restoring editing controls.
Closing the main window during inspection closes/cancels the inspector first;
close the main window again after inspection has finished.
## Posting workflow
The desktop's **Nest > Post > (post processor)** workflow checks every plate after
post settings are accepted and before asking where to save CNC output. The review
shows all three check categories, even when there are no findings: