diff --git a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs
new file mode 100644
index 0000000..6f357cc
--- /dev/null
+++ b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs
@@ -0,0 +1,66 @@
+using System;
+using OpenNest.CNC;
+
+namespace OpenNest.Diagnostics;
+
+///
+/// Cheap ordered identity/pose check, not a geometry hash. In-place geometry editors must
+/// explicitly invalidate before mutating. Capture and match only on the model's UI thread.
+///
+public sealed class OverlapGeometryStamp
+{
+ private readonly Plate plate;
+ private readonly Entry[] entries;
+
+ private OverlapGeometryStamp(Plate plate)
+ {
+ this.plate = plate;
+ entries = new Entry[plate.Parts.Count];
+ for (var i = 0; i < entries.Length; i++)
+ entries[i] = new Entry(plate.Parts[i]);
+ }
+
+ public static OverlapGeometryStamp Capture(Plate plate) => new(plate);
+
+ public bool Matches(Plate current)
+ {
+ if (!ReferenceEquals(plate, current) || current.Parts.Count != entries.Length)
+ return false;
+ for (var i = 0; i < entries.Length; i++)
+ if (!entries[i].Matches(current.Parts[i]))
+ return false;
+ return true;
+ }
+
+ private readonly struct Entry
+ {
+ private readonly Part part;
+ private readonly Drawing drawing;
+ private readonly Program placedProgram;
+ private readonly Program cleanProgram;
+ private readonly long x, y, rotation;
+ private readonly bool isCutOff;
+
+ public Entry(Part part)
+ {
+ this.part = part;
+ drawing = part.BaseDrawing;
+ placedProgram = part.Program;
+ cleanProgram = drawing.Program;
+ x = BitConverter.DoubleToInt64Bits(part.Location.X);
+ y = BitConverter.DoubleToInt64Bits(part.Location.Y);
+ rotation = BitConverter.DoubleToInt64Bits(part.Rotation);
+ isCutOff = drawing.IsCutOff;
+ }
+
+ public bool Matches(Part current) =>
+ ReferenceEquals(part, current)
+ && ReferenceEquals(drawing, current.BaseDrawing)
+ && ReferenceEquals(placedProgram, current.Program)
+ && ReferenceEquals(cleanProgram, current.BaseDrawing.Program)
+ && x == BitConverter.DoubleToInt64Bits(current.Location.X)
+ && y == BitConverter.DoubleToInt64Bits(current.Location.Y)
+ && rotation == BitConverter.DoubleToInt64Bits(current.Rotation)
+ && isCutOff == current.BaseDrawing.IsCutOff;
+ }
+}
diff --git a/OpenNest.Core/Diagnostics/OverlapReportState.cs b/OpenNest.Core/Diagnostics/OverlapReportState.cs
new file mode 100644
index 0000000..af10966
--- /dev/null
+++ b/OpenNest.Core/Diagnostics/OverlapReportState.cs
@@ -0,0 +1,106 @@
+using System.Collections.Generic;
+
+namespace OpenNest.Diagnostics;
+
+public enum OverlapDisplayMode { Off, Areas }
+public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Failed, Canceled, Stale }
+
+/// UI-thread lifecycle policy, independent of workers, GDI and view transforms.
+public sealed class OverlapReportState
+{
+ private OverlapGeometryStamp stamp;
+ private int uncheckedPartCount;
+
+ public long Generation { get; private set; }
+ public OverlapCheckStatus Status { get; private set; } = OverlapCheckStatus.NotChecked;
+ public OverlapDisplayMode DisplayMode { get; set; } = OverlapDisplayMode.Areas;
+ public PlateOverlapReport Report { get; private set; }
+ public bool IsRunning => Status == OverlapCheckStatus.Checking;
+
+ public string Message => Status switch
+ {
+ OverlapCheckStatus.Checking => "Checking overlaps…",
+ OverlapCheckStatus.Current => Report.Pairs.Count == 0
+ ? "No material overlaps detected" : $"Overlaps: {Report.Pairs.Count} pairs",
+ OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; "
+ + $"{uncheckedPartCount} parts could not be checked",
+ OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again",
+ OverlapCheckStatus.Canceled => "Overlap check canceled",
+ OverlapCheckStatus.Stale => "Overlap check out of date — run Check Overlaps again",
+ _ => "Overlaps: not checked"
+ };
+
+ public long Begin(Plate plate)
+ {
+ Clear(OverlapCheckStatus.Checking);
+ stamp = OverlapGeometryStamp.Capture(plate);
+ DisplayMode = OverlapDisplayMode.Areas;
+ return Generation;
+ }
+
+ public bool TryPublish(long generation, Plate plate, PlateOverlapReport report)
+ {
+ if (!CanComplete(generation, plate))
+ return false;
+ Report = report;
+ uncheckedPartCount = CountUncheckedParts(report.Issues);
+ Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete;
+ return true;
+ }
+
+ public bool TryFail(long generation, Plate plate)
+ {
+ if (!CanComplete(generation, plate))
+ return false;
+ Clear(OverlapCheckStatus.Failed);
+ return true;
+ }
+
+ private bool CanComplete(long generation, Plate plate) =>
+ generation == Generation && IsRunning && EnsureFresh(plate);
+
+ public bool EnsureFresh(Plate plate)
+ {
+ if (stamp == null)
+ return false;
+ if (stamp.Matches(plate))
+ return true;
+ Invalidate();
+ return false;
+ }
+
+ public void Invalidate()
+ {
+ if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete)
+ Clear(OverlapCheckStatus.Stale);
+ }
+
+ public void Cancel()
+ {
+ if (IsRunning)
+ Clear(OverlapCheckStatus.Canceled);
+ }
+
+ public void Reset() => Clear(OverlapCheckStatus.NotChecked);
+
+ private void Clear(OverlapCheckStatus status)
+ {
+ Generation++;
+ Report = null;
+ uncheckedPartCount = 0;
+ stamp = null;
+ Status = status;
+ }
+
+ public static int CountUncheckedParts(IEnumerable issues)
+ {
+ var ids = new HashSet();
+ foreach (var issue in issues)
+ {
+ ids.Add(issue.PartAId);
+ if (issue.PartBId.HasValue)
+ ids.Add(issue.PartBId.Value);
+ }
+ return ids.Count;
+ }
+}
diff --git a/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs b/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs
new file mode 100644
index 0000000..1073f03
--- /dev/null
+++ b/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs
@@ -0,0 +1,212 @@
+using OpenNest.CNC;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.Diagnostics;
+
+public class OverlapReportStateTests
+{
+ [Fact]
+ public void RestartRejectsOldSuccessAndFailureWithoutDisturbingNewRequest()
+ {
+ var plate = PlateWithParts();
+ var state = new OverlapReportState();
+ var old = state.Begin(plate);
+ var current = state.Begin(plate);
+ Assert.False(state.TryPublish(old, plate, Analyze(plate)));
+ Assert.False(state.TryFail(old, plate));
+ Assert.Equal(OverlapCheckStatus.Checking, state.Status);
+ Assert.True(state.TryPublish(current, plate, Analyze(plate)));
+ Assert.Equal(OverlapCheckStatus.Current, state.Status);
+ Assert.Equal("Overlaps: 1 pairs", state.Message);
+ }
+
+ [Theory]
+ [InlineData("move-x")]
+ [InlineData("move-y")]
+ [InlineData("rotate")]
+ [InlineData("reorder")]
+ [InlineData("replace-part")]
+ [InlineData("replace-drawing")]
+ [InlineData("placed-program")]
+ [InlineData("clean-program")]
+ [InlineData("add")]
+ [InlineData("remove")]
+ [InlineData("plate")]
+ public void ExactOrderedStampRejectsEditsBeforePublishAndAfterCompletion(string edit)
+ {
+ foreach (var publishFirst in new[] { false, true })
+ {
+ var plate = PlateWithParts();
+ var state = new OverlapReportState();
+ var request = state.Begin(plate);
+ var report = Analyze(plate);
+ if (publishFirst)
+ Assert.True(state.TryPublish(request, plate, report));
+ var part = plate.Parts[0];
+ switch (edit)
+ {
+ case "move-x": part.Offset(1e-10, 0); break;
+ case "move-y": part.Offset(0, 1e-10); break;
+ case "rotate": part.Rotate(0.01); break;
+ case "reorder": (plate.Parts[0], plate.Parts[1]) = (plate.Parts[1], plate.Parts[0]); break;
+ case "replace-part": plate.Parts[0] = part.CloneAtOffset(new Vector()); break;
+ case "replace-drawing": plate.Parts[0] = Rectangle(); break;
+ case "placed-program": part.Update(); break;
+ case "clean-program": part.BaseDrawing.Program = (Program)part.BaseDrawing.Program.Clone(); break;
+ case "add": plate.Parts.Add(Rectangle()); break;
+ case "remove": plate.Parts.RemoveAt(0); break;
+ case "plate":
+ var replacement = new Plate();
+ replacement.Parts.AddRange(plate.Parts);
+ plate = replacement;
+ break;
+ }
+ Assert.False(state.EnsureFresh(plate));
+ Assert.False(state.TryPublish(request, plate, report));
+ Assert.Null(state.Report);
+ Assert.Equal(OverlapCheckStatus.Stale, state.Status);
+ Assert.Equal("Overlap check out of date — run Check Overlaps again", state.Message);
+ }
+ }
+
+ [Fact]
+ public void CancelFailureAndIncompleteAreNeverClear()
+ {
+ var plate = new Plate();
+ var state = new OverlapReportState();
+ var request = state.Begin(plate);
+ state.Cancel();
+ Assert.False(state.TryPublish(request, plate, Analyze(plate)));
+ Assert.Equal(OverlapCheckStatus.Canceled, state.Status);
+ Assert.Null(state.Report);
+ request = state.Begin(plate);
+ Assert.True(state.TryFail(request, plate));
+ Assert.Equal(OverlapCheckStatus.Failed, state.Status);
+ Assert.Contains("failed", state.Message);
+ Assert.Null(state.Report);
+
+ plate.Parts.Add(new Part(new Drawing("open", new Program())));
+ request = state.Begin(plate);
+ Assert.True(state.TryPublish(request, plate, Analyze(plate)));
+ Assert.Equal(OverlapCheckStatus.Incomplete, state.Status);
+ Assert.Equal("Overlap check incomplete: 0 overlapping pairs; 1 parts could not be checked", state.Message);
+ state.Invalidate();
+ Assert.Equal(OverlapCheckStatus.Stale, state.Status);
+ Assert.Null(state.Report);
+ }
+
+ [Fact]
+ public void IssueCountIncludesBothPairIdsAndDeduplicatesAcrossAllIssues()
+ {
+ var issues = new[]
+ {
+ new PlateOverlapIssue(2, null, "input"),
+ new PlateOverlapIssue(2, 4, "pair"),
+ new PlateOverlapIssue(4, 7, "pair"),
+ new PlateOverlapIssue(7, null, "input")
+ };
+ Assert.Equal(3, OverlapReportState.CountUncheckedParts(issues));
+ }
+
+ [Fact]
+ public void DisplayAndNonGeometryChangesRetainReportAndGeneration()
+ {
+ var plate = PlateWithParts();
+ var state = new OverlapReportState();
+ Assert.Equal(OverlapDisplayMode.Areas, state.DisplayMode);
+ var request = state.Begin(plate);
+ var report = Analyze(plate);
+ Assert.True(state.TryPublish(request, plate, report));
+ state.DisplayMode = OverlapDisplayMode.Off;
+ plate.Parts[0].BaseDrawing.Name = "renamed";
+ plate.Quantity = 2;
+ plate.PartSpacing = 123;
+ Assert.True(state.EnsureFresh(plate));
+ Assert.Same(report, state.Report);
+ Assert.Equal(request, state.Generation);
+ state.DisplayMode = OverlapDisplayMode.Areas;
+ Assert.Same(report, state.Report);
+ Assert.Equal(request, state.Generation);
+ state.DisplayMode = OverlapDisplayMode.Off;
+ state.Begin(plate);
+ Assert.Equal(OverlapDisplayMode.Areas, state.DisplayMode);
+ Assert.Null(state.Report);
+ }
+
+ [Fact]
+ public void NewPlateResetsStateButRetainsDocumentDisplayPreference()
+ {
+ var plate = PlateWithParts();
+ var state = new OverlapReportState();
+ var request = state.Begin(plate);
+ state.DisplayMode = OverlapDisplayMode.Off;
+ state.Reset();
+ Assert.Equal(OverlapCheckStatus.NotChecked, state.Status);
+ Assert.Null(state.Report);
+ Assert.Equal(OverlapDisplayMode.Off, state.DisplayMode);
+ Assert.False(state.TryPublish(request, plate, Analyze(plate)));
+ }
+
+ [Fact]
+ public void CutoffSlotsRemainPartOfTheOrderedIdentityStamp()
+ {
+ var plate = PlateWithParts();
+ var cutoff = Rectangle();
+ cutoff.BaseDrawing.IsCutOff = true;
+ plate.Parts.Insert(0, cutoff);
+ var state = new OverlapReportState();
+ Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
+ var pair = Assert.Single(state.Report.Pairs);
+ Assert.Equal((1, 2), (pair.PartAId, pair.PartBId));
+ plate.Parts.RemoveAt(0);
+ Assert.False(state.EnsureFresh(plate));
+ Assert.Null(state.Report);
+ }
+
+ [Fact]
+ public void InPlaceGeometryEditorsMustExplicitlyInvalidate()
+ {
+ var plate = PlateWithParts();
+ var state = new OverlapReportState();
+ var request = state.Begin(plate);
+ var report = Analyze(plate);
+ // Deliberately not serialized or converted by the stamp.
+ plate.Parts[0].BaseDrawing.Program.Codes.Clear();
+ Assert.True(state.EnsureFresh(plate));
+ state.Invalidate();
+ Assert.False(state.TryPublish(request, plate, report));
+ Assert.Equal(OverlapCheckStatus.Stale, state.Status);
+ }
+
+ [Fact]
+ public void EmptyCompleteReportAloneCanSayClear()
+ {
+ var plate = new Plate();
+ var state = new OverlapReportState();
+ Assert.Equal(OverlapCheckStatus.NotChecked, state.Status);
+ Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate)));
+ Assert.Equal("No material overlaps detected", state.Message);
+ }
+
+ private static PlateOverlapReport Analyze(Plate plate) => PlateOverlapAnalyzer.Analyze(plate.Parts.ToArray());
+
+ private static Plate PlateWithParts()
+ {
+ var plate = new Plate();
+ plate.Parts.Add(Rectangle());
+ plate.Parts.Add(Rectangle());
+ return plate;
+ }
+
+ private static Part Rectangle()
+ {
+ var program = new Program(Mode.Absolute);
+ program.Codes.Add(new RapidMove(0, 0));
+ program.Codes.Add(new LinearMove(4, 0));
+ program.Codes.Add(new LinearMove(4, 4));
+ program.Codes.Add(new LinearMove(0, 4));
+ program.Codes.Add(new LinearMove(0, 0));
+ return new Part(new Drawing("same name", program));
+ }
+}
diff --git a/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs
new file mode 100644
index 0000000..3085008
--- /dev/null
+++ b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs
@@ -0,0 +1,436 @@
+using System.Collections.Concurrent;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+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;
+
+[CollectionDefinition("Overlap overlay", DisableParallelization = true)]
+public class OverlapOverlayCollection;
+
+[Collection("Overlap overlay")]
+public class PlateOverlapOverlayTests
+{
+ [Fact]
+ public void RestartAndCancelRejectLateCompletionsOnTheUiThread() => RunSta(() =>
+ {
+ using var run = new OverlayRun();
+ var uiThread = Environment.CurrentManagedThreadId;
+ run.View.OverlapStateChanged += (_, _) => Assert.Equal(uiThread, Environment.CurrentManagedThreadId);
+ var first = run.Start();
+ var old = run.Next();
+ var second = run.Start();
+ var current = run.Next();
+ Assert.True(old.Token.IsCancellationRequested);
+ old.Complete();
+ run.Pump(first);
+ Assert.Equal(OverlapCheckStatus.Checking, run.View.OverlapStatus);
+ current.Complete();
+ run.Pump(second);
+ Assert.Equal(OverlapCheckStatus.Current, run.View.OverlapStatus);
+ Assert.Single(run.View.OverlapReport.Pairs);
+
+ var canceled = run.Start();
+ var late = run.Next();
+ run.View.CancelOverlapCheck();
+ Assert.True(late.Token.IsCancellationRequested);
+ late.Complete();
+ run.Pump(canceled);
+ Assert.Equal(OverlapCheckStatus.Canceled, run.View.OverlapStatus);
+ Assert.Null(run.View.OverlapReport);
+ });
+
+ [Theory]
+ [InlineData("move")]
+ [InlineData("add")]
+ [InlineData("remove")]
+ [InlineData("replace")]
+ [InlineData("reorder")]
+ [InlineData("editor")]
+ [InlineData("plate")]
+ public void EditOrPlateSwitchRejectsPendingReport(string edit) => RunSta(() =>
+ {
+ using var run = new OverlayRun();
+ var task = run.Start();
+ var work = run.Next();
+ var plate = run.View.Plate;
+ switch (edit)
+ {
+ case "move": plate.Parts[0].Offset(1e-10, 0); break;
+ case "add": plate.Parts.Add(Rectangle()); break;
+ case "remove": plate.Parts.RemoveAt(0); break;
+ case "replace": plate.Parts[0] = Rectangle(); break;
+ case "reorder": (plate.Parts[0], plate.Parts[1]) = (plate.Parts[1], plate.Parts[0]); break;
+ case "editor": run.View.InvalidateOverlapCheck(); break;
+ case "plate": run.View.Plate = new Plate(); break;
+ }
+ if (edit != "move")
+ Assert.True(work.Token.IsCancellationRequested);
+ work.Complete();
+ run.Pump(task);
+ Assert.True(work.Token.IsCancellationRequested);
+ Assert.Null(run.View.OverlapReport);
+ Assert.Equal(edit == "plate" ? OverlapCheckStatus.NotChecked : OverlapCheckStatus.Stale,
+ run.View.OverlapStatus);
+ });
+
+ [Fact]
+ public void PaintChecksFreshnessEvenWhenDisplayIsOff() => RunSta(() =>
+ {
+ using var run = new OverlayRun();
+ run.Finish();
+ using var image = new Bitmap(240, 240);
+ using var graphics = Graphics.FromImage(image);
+ run.View.OverlapOverlay.Draw(graphics);
+ Assert.NotNull(run.View.OverlapOverlay.CachedPath);
+ run.View.OverlapDisplay = OverlapDisplayMode.Off;
+ run.View.Plate.Parts[0].Offset(1e-10, 0);
+ run.View.OverlapOverlay.Draw(graphics);
+ Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
+ Assert.Null(run.View.OverlapReport);
+ Assert.Null(run.View.OverlapOverlay.CachedPath);
+ });
+
+ [Fact]
+ public void DisplayPanZoomAndPreviewDoNotAnalyzeOrChangeCommittedParts() => RunSta(() =>
+ {
+ using var run = new OverlayRun();
+ var parts = run.View.Plate.Parts.ToArray();
+ var locations = parts.Select(p => p.Location).ToArray();
+ var programs = parts.Select(p => p.Program).ToArray();
+ var quantities = parts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
+ run.View.SelectAll();
+ var selection = run.View.SelectedParts.ToArray();
+ run.View.SetActiveParts(new List { Rectangle() });
+ run.View.OverlapDisplay = OverlapDisplayMode.Off;
+ run.Finish();
+ Assert.Equal(OverlapDisplayMode.Areas, run.View.OverlapDisplay);
+ Assert.Single(run.View.OverlapReport.Pairs); // Preview is not a third input.
+ var report = run.View.OverlapReport;
+ using var image = new Bitmap(240, 240);
+ using var graphics = Graphics.FromImage(image);
+ run.View.OverlapOverlay.Draw(graphics);
+ var path = run.View.OverlapOverlay.CachedPath;
+ Assert.Equal(FillMode.Winding, path.FillMode);
+ run.View.Pan(35, -20);
+ run.View.OverlapOverlay.Draw(graphics);
+ Assert.Same(path, run.View.OverlapOverlay.CachedPath);
+ run.View.OverlapDisplay = OverlapDisplayMode.Off;
+ run.View.OverlapDisplay = OverlapDisplayMode.Areas;
+ run.View.OverlapOverlay.Draw(graphics);
+ Assert.Same(path, run.View.OverlapOverlay.CachedPath);
+ run.View.ZoomToPoint(new Vector(), 2);
+ run.View.OverlapOverlay.Draw(graphics);
+ Assert.NotSame(path, run.View.OverlapOverlay.CachedPath);
+ Assert.Same(report, run.View.OverlapReport);
+ Assert.Equal(1, run.Calls);
+ Assert.Equal(parts, run.View.Plate.Parts.ToArray());
+ Assert.Equal(locations, parts.Select(p => p.Location));
+ Assert.Equal(programs, parts.Select(p => p.Program));
+ Assert.Equal(quantities, parts.Select(p => p.BaseDrawing.Quantity.Nested));
+ Assert.Equal(selection, run.View.SelectedParts);
+ });
+
+ [Fact]
+ public void TripleOverlapUsesOneFillAndGraphCoordinatesDoNotDoublePan() => RunSta(() =>
+ {
+ using var run = new OverlayRun();
+ run.View.Plate.Parts.Clear();
+ run.View.Plate.Parts.Add(Rectangle());
+ run.View.Plate.Parts.Add(Rectangle(2));
+ run.View.Plate.Parts.Add(Rectangle(3));
+ run.Finish();
+ run.View.ZoomToPoint(new Vector(), 20);
+ run.View.SetOrigin(60, 220);
+ using var image = new Bitmap(300, 300);
+ using var graphics = Graphics.FromImage(image);
+ graphics.Clear(Color.White);
+ graphics.TranslateTransform(60, 220);
+ var transform = graphics.Transform.Elements;
+ run.View.OverlapOverlay.Draw(graphics);
+ Assert.Equal(transform, graphics.Transform.Elements); // Label restores graph space.
+ Assert.Equal(image.GetPixel(110, 180), image.GetPixel(130, 180)); // Double/triple material, same alpha.
+ Assert.NotEqual(Color.White.ToArgb(), image.GetPixel(130, 180).ToArgb());
+ Assert.Equal(Color.White.ToArgb(), image.GetPixel(70, 180).ToArgb());
+ });
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void HandleDestructionOrDisposalNeverResurrectsRequests(bool dispose) => RunSta(() =>
+ {
+ using var run = new OverlayRun();
+ run.Finish();
+ using var image = new Bitmap(100, 100);
+ using var graphics = Graphics.FromImage(image);
+ run.View.OverlapOverlay.Draw(graphics);
+ Assert.NotNull(run.View.OverlapOverlay.CachedPath);
+ var task = run.Start();
+ var work = run.Next();
+ if (dispose)
+ run.View.Dispose();
+ else
+ run.View.Recreate();
+ Assert.True(work.Token.IsCancellationRequested);
+ Assert.Null(run.View.OverlapOverlay.CachedPath);
+ work.Complete();
+ run.Pump(task);
+ Assert.Null(run.View.OverlapReport);
+ Assert.False(run.View.IsOverlapCheckRunning);
+ });
+
+ [Fact]
+ public void CompletedPathIsReleasedOnHandleRecreationAndCannotReappear() => RunSta(() =>
+ {
+ using var run = new OverlayRun();
+ run.Finish();
+ using var image = new Bitmap(100, 100);
+ using var graphics = Graphics.FromImage(image);
+ run.View.OverlapOverlay.Draw(graphics);
+ Assert.NotNull(run.View.OverlapOverlay.CachedPath);
+ run.View.Recreate();
+ Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
+ Assert.Null(run.View.OverlapOverlay.CachedPath);
+ run.View.OverlapOverlay.Draw(graphics);
+ Assert.Null(run.View.OverlapOverlay.CachedPath);
+ Assert.Null(run.View.OverlapReport);
+ Assert.Equal(1, run.Calls);
+ });
+
+ [Fact]
+ public void FailureAndIncompleteAreVisibleAndNeverReportedAsClear() => RunSta(() =>
+ {
+ using var run = new OverlayRun();
+ var task = run.Start();
+ run.Next().Fail();
+ run.Pump(task);
+ Assert.Equal(OverlapCheckStatus.Failed, run.View.OverlapStatus);
+ Assert.Contains("failed", run.View.Status);
+ Assert.Null(run.View.OverlapReport);
+ run.View.Plate.Parts.Add(new Part(new Drawing("invalid", new Program())));
+ run.Finish();
+ Assert.Equal(OverlapCheckStatus.Incomplete, run.View.OverlapStatus);
+ Assert.Contains("incomplete: 1 overlapping pairs; 1 parts", run.View.Status);
+ });
+
+ [Fact]
+ public void SnapshotKeepsOriginalLabelsWhenLiveNamesChange() => RunSta(() =>
+ {
+ using var run = new OverlayRun();
+ var task = run.Start();
+ var work = run.Next();
+ run.View.Plate.Parts[0].BaseDrawing.Name = "new label";
+ work.Complete();
+ run.Pump(task);
+ Assert.Equal("same name", Assert.Single(run.View.OverlapReport.Pairs).PartAName);
+ Assert.Equal(OverlapCheckStatus.Current, run.View.OverlapStatus);
+ });
+
+ [Theory]
+ [InlineData("add")]
+ [InlineData("remove")]
+ [InlineData("replace")]
+ [InlineData("clear")]
+ public void CollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() =>
+ {
+ using var run = new OverlayRun();
+ run.Finish();
+ using var image = new Bitmap(100, 100);
+ using var graphics = Graphics.FromImage(image);
+ run.View.OverlapOverlay.Draw(graphics);
+ Assert.NotNull(run.View.OverlapOverlay.CachedPath);
+ switch (edit)
+ {
+ case "add": run.View.Plate.Parts.Add(Rectangle()); break;
+ case "remove": run.View.Plate.Parts.RemoveAt(0); break;
+ case "replace": run.View.Plate.Parts[0] = Rectangle(); break;
+ case "clear": run.View.Plate.Parts.Clear(); break;
+ }
+ Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
+ Assert.Null(run.View.OverlapReport);
+ Assert.Null(run.View.OverlapOverlay.CachedPath);
+ });
+
+ [Fact]
+ public void MenusFollowMdiActivationDocumentDisplayAndRunningState() => RunSta(() =>
+ {
+ using var host = new MenuHost();
+ host.Show();
+ var check = Menu(host, "mnuOverlapCheckActive");
+ var cancel = Menu(host, "mnuOverlapCancel");
+ var off = Menu(host, "mnuOverlapOff");
+ var areas = Menu(host, "mnuOverlapAreas");
+ Assert.False(check.Enabled);
+ Assert.False(cancel.Enabled);
+ Assert.False(off.Enabled);
+ Assert.False(areas.Enabled);
+ Assert.All(new[] { check, cancel, off, areas }, item => Assert.Equal(Keys.None, item.ShortcutKeys));
+
+ using var first = new EditNestForm(new Nest("first")) { MdiParent = host };
+ first.Show();
+ Assert.True(check.Enabled);
+ Assert.True(areas.Checked);
+ off.PerformClick();
+ Assert.Equal(OverlapDisplayMode.Off, first.OverlapDisplay);
+ using var second = new EditNestForm(new Nest("second")) { MdiParent = host };
+ second.Show();
+ Assert.True(areas.Checked);
+ Assert.False(off.Checked);
+ first.Activate();
+ Assert.True(off.Checked);
+ Assert.False(areas.Checked);
+ Assert.Equal(OverlapDisplayMode.Areas, second.OverlapDisplay);
+
+ var previous = SynchronizationContext.Current;
+ var context = new PumpContext();
+ using var pending = new BlockingCollection();
+ SynchronizationContext.SetSynchronizationContext(context);
+ try
+ {
+ first.PlateView.OverlapOverlay.Analyze = (snapshot, token) =>
+ {
+ var request = new Work(snapshot, token);
+ pending.Add(request);
+ return request.WaitForResult();
+ };
+ var task = first.CheckOverlapsAsync();
+ Assert.True(pending.TryTake(out var request, TimeSpan.FromSeconds(15)));
+ Assert.True(cancel.Enabled);
+ Assert.True(areas.Checked); // Checking from Off selects Areas.
+ cancel.PerformClick();
+ Assert.False(cancel.Enabled);
+ Assert.True(request!.Token.IsCancellationRequested);
+ request.Complete();
+ context.Pump(task);
+ Assert.Equal(OverlapCheckStatus.Canceled, first.PlateView.OverlapStatus);
+ }
+ finally
+ {
+ SynchronizationContext.SetSynchronizationContext(previous);
+ }
+ first.Dispose();
+ second.Dispose();
+ Assert.False(check.Enabled);
+ Assert.False(cancel.Enabled);
+ });
+
+ private static ToolStripMenuItem Menu(MainForm host, string name) =>
+ (ToolStripMenuItem)host.MainMenuStrip!.Items.Find(name, true).Single();
+
+ private sealed class MenuHost : MainForm
+ {
+ // Do not run startup migrations/automatic new-document creation or save test window settings.
+ protected override void OnLoad(EventArgs e) { }
+ protected override void OnClosing(System.ComponentModel.CancelEventArgs e) { }
+ }
+
+ private sealed class TestView : PlateView
+ {
+ public void Pan(float x, float y) { origin.X += x; origin.Y += y; }
+ public void SetOrigin(float x, float y) => origin = new PointF(x, y);
+ public void Recreate() => RecreateHandle();
+ }
+
+ private sealed class OverlayRun : IDisposable
+ {
+ private readonly PumpContext context = new();
+ private readonly SynchronizationContext? previous;
+ private readonly BlockingCollection work = new();
+ public TestView View { get; } = new();
+ public int Calls;
+
+ public OverlayRun()
+ {
+ View.CreateControl();
+ previous = SynchronizationContext.Current;
+ SynchronizationContext.SetSynchronizationContext(context);
+ View.Plate.Parts.Add(Rectangle());
+ View.Plate.Parts.Add(Rectangle(1));
+ View.OverlapOverlay.Analyze = (snapshot, token) =>
+ {
+ Interlocked.Increment(ref Calls);
+ var request = new Work(snapshot, token);
+ work.Add(request);
+ return request.WaitForResult();
+ };
+ }
+
+ public Task Start() => View.CheckOverlapsAsync();
+ public Work Next()
+ {
+ Assert.True(work.TryTake(out var result, TimeSpan.FromSeconds(15)), "Worker did not start.");
+ return result!;
+ }
+ public void Pump(Task task) => context.Pump(task);
+ public void Finish()
+ {
+ var task = Start();
+ Next().Complete();
+ Pump(task);
+ }
+ public void Dispose()
+ {
+ View.Dispose();
+ SynchronizationContext.SetSynchronizationContext(previous);
+ work.Dispose();
+ }
+ }
+
+ private sealed class Work(PlateOverlapSnapshot snapshot, CancellationToken token)
+ {
+ public CancellationToken Token { get; } = token;
+ public TaskCompletionSource Result { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ // Only called by Task.Run workers; the STA supplies the result and pumps continuations.
+ public PlateOverlapReport WaitForResult() => Result.Task.GetAwaiter().GetResult();
+ public void Complete() => Result.SetResult(PlateOverlapAnalyzer.Analyze(snapshot));
+ public void Fail() => Result.SetException(new InvalidOperationException("test failure"));
+ }
+
+ private sealed class PumpContext : SynchronizationContext
+ {
+ private readonly BlockingCollection callbacks = new();
+ public override void Post(SendOrPostCallback callback, object? state) => callbacks.Add(() => callback(state));
+ public void Pump(Task task)
+ {
+ while (!task.IsCompleted)
+ {
+ Assert.True(callbacks.TryTake(out var callback, TimeSpan.FromSeconds(15)), "UI continuation did not arrive.");
+ callback!();
+ }
+ task.GetAwaiter().GetResult();
+ }
+ }
+
+ private static Part Rectangle(double x = 0)
+ {
+ var program = new Program(Mode.Absolute);
+ program.Codes.Add(new RapidMove(0, 0));
+ program.Codes.Add(new LinearMove(4, 0));
+ program.Codes.Add(new LinearMove(4, 4));
+ program.Codes.Add(new LinearMove(0, 4));
+ program.Codes.Add(new LinearMove(0, 0));
+ return new Part(new Drawing("same name", program), new Vector(x, 0));
+ }
+
+ private static void RunSta(System.Action action)
+ {
+ Exception? failure = null;
+ var thread = new Thread(() =>
+ {
+ try { action(); }
+ catch (Exception ex) { failure = ex; }
+ })
+ { IsBackground = true };
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "The STA test did not complete.");
+ if (failure != null)
+ ExceptionDispatchInfo.Capture(failure).Throw();
+ }
+}
diff --git a/OpenNest/Controls/OverlapOverlayController.cs b/OpenNest/Controls/OverlapOverlayController.cs
new file mode 100644
index 0000000..61ef2cd
--- /dev/null
+++ b/OpenNest/Controls/OverlapOverlayController.cs
@@ -0,0 +1,287 @@
+using System;
+using System.Diagnostics;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using OpenNest.Collections;
+using OpenNest.Diagnostics;
+
+namespace OpenNest.Controls;
+
+///
+/// UI-thread owner of manual overlap requests and their cached display geometry.
+/// Workers see only owned analyzer snapshots, never the live plate or view.
+///
+internal sealed class OverlapOverlayController : IDisposable
+{
+ private readonly PlateView view;
+ private readonly OverlapReportState state = new();
+ private ObservableList observedParts;
+ private CancellationTokenSource cancellation;
+ private GraphicsPath path;
+ private PlateOverlapReport pathReport;
+ private float pathScale;
+ private bool disposed;
+
+ public OverlapOverlayController(PlateView view) => this.view = view;
+
+ public event EventHandler StateChanged;
+ public OverlapCheckStatus Status => state.Status;
+ public PlateOverlapReport Report => state.Report;
+ public bool IsRunning => state.IsRunning;
+
+ // Deterministic worker seam for STA lifecycle tests. Capture always stays on the UI thread.
+ internal Func Analyze { get; set; }
+ = PlateOverlapAnalyzer.Analyze;
+ internal GraphicsPath CachedPath => path;
+
+ public OverlapDisplayMode DisplayMode
+ {
+ get => state.DisplayMode;
+ set
+ {
+ if (state.DisplayMode == value)
+ return;
+ state.DisplayMode = value;
+ NotifyChanged();
+ }
+ }
+
+ public void SetPlate(Plate plate)
+ {
+ Unsubscribe();
+ CancelWorker();
+ state.Reset();
+ ReleasePath();
+ observedParts = plate?.Parts;
+ if (observedParts != null)
+ {
+ observedParts.ItemAdded += PartAdded;
+ observedParts.ItemRemoved += PartRemoved;
+ observedParts.ItemChanged += PartChanged;
+ }
+ NotifyChanged();
+ }
+
+ public async Task CheckAsync()
+ {
+ if (!CanUseView || view.Plate == null)
+ return;
+ if (view.InvokeRequired)
+ throw new InvalidOperationException("Overlap checks must be started on the UI thread.");
+
+ CancelWorker();
+ var plate = view.Plate;
+ var generation = state.Begin(plate);
+ var source = new CancellationTokenSource();
+ cancellation = source;
+ var token = source.Token;
+ ReleasePath();
+ NotifyChanged();
+ try
+ {
+ // Do not include PreviewManager parts. Capture owns clean geometry and stable names.
+ var snapshot = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), token);
+ var analyze = Analyze;
+ var report = await Task.Run(() => analyze(snapshot, token), token);
+ if (!CanUseView || generation != state.Generation || !ReferenceEquals(plate, view.Plate))
+ return;
+ if (token.IsCancellationRequested)
+ state.Cancel();
+ else if (!state.TryPublish(generation, view.Plate, report))
+ CancelWorker(); // A pose/reference mismatch discovered at publication is stale.
+ NotifyChanged();
+ }
+ catch (OperationCanceledException) when (token.IsCancellationRequested)
+ {
+ if (CanUseView && generation == state.Generation)
+ {
+ state.Cancel();
+ NotifyChanged();
+ }
+ }
+ catch (Exception ex)
+ {
+ Debug.WriteLine($"Overlap check failed: {ex}");
+ if (CanUseView && generation == state.Generation)
+ {
+ if (!state.TryFail(generation, view.Plate))
+ CancelWorker();
+ NotifyChanged();
+ }
+ }
+ finally
+ {
+ if (ReferenceEquals(cancellation, source))
+ cancellation = null;
+ source.Dispose();
+ }
+ }
+
+ private bool CanUseView => !disposed && !view.IsDisposed && !view.Disposing && view.IsHandleCreated;
+
+ public void Cancel()
+ {
+ if (!state.IsRunning)
+ return;
+ CancelWorker();
+ state.Cancel();
+ ReleasePath();
+ NotifyChanged();
+ }
+
+ public void Invalidate()
+ {
+ var generation = state.Generation;
+ state.Invalidate();
+ if (generation == state.Generation)
+ return;
+ CancelWorker();
+ ReleasePath();
+ NotifyChanged();
+ }
+
+ public void ReleaseHandle()
+ {
+ CancelWorker();
+ state.Cancel();
+ state.Invalidate();
+ ReleasePath();
+ NotifyChanged();
+ }
+
+ private void CancelWorker()
+ {
+ // The awaiting request disposes its own source after the worker exits.
+ var source = cancellation;
+ cancellation = null;
+ source?.Cancel();
+ }
+
+ private void NotifyChanged()
+ {
+ if (disposed || view.IsDisposed || view.Disposing)
+ return;
+ view.Status = state.Message;
+ view.Invalidate();
+ StateChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void PartAdded(object sender, ItemAddedEventArgs e) => Invalidate();
+ private void PartRemoved(object sender, ItemRemovedEventArgs e) => Invalidate();
+ private void PartChanged(object sender, ItemChangedEventArgs e) => Invalidate();
+
+ private void Unsubscribe()
+ {
+ if (observedParts == null)
+ return;
+ observedParts.ItemAdded -= PartAdded;
+ observedParts.ItemRemoved -= PartRemoved;
+ observedParts.ItemChanged -= PartChanged;
+ observedParts = null;
+ }
+
+ public void Draw(Graphics graphics)
+ {
+ if (disposed)
+ return;
+ var generation = state.Generation;
+ state.EnsureFresh(view.Plate);
+ if (generation != state.Generation)
+ {
+ CancelWorker();
+ ReleasePath();
+ NotifyChanged();
+ }
+
+ if (state.DisplayMode == OverlapDisplayMode.Areas && state.Report != null)
+ {
+ EnsurePath();
+ if (path.PointCount > 0)
+ {
+ using var brush = new SolidBrush(Color.FromArgb(100, 255, 0, 80));
+ // A single winding fill is the union: triple overlap is not painted darker.
+ graphics.FillPath(brush, path);
+ }
+ }
+ DrawStateLabel(graphics);
+ }
+
+ private void EnsurePath()
+ {
+ if (ReferenceEquals(pathReport, state.Report) && pathScale == view.ViewScale && path != null)
+ return;
+ ReleasePath();
+ var next = new GraphicsPath(FillMode.Winding);
+ try
+ {
+ foreach (var pair in state.Report.Pairs)
+ {
+ foreach (var region in pair.Regions)
+ {
+ var vertices = region.Vertices;
+ var points = new PointF[vertices.Count - 1]; // Analyzer repeats the closing vertex.
+ var signedArea = 0.0;
+ var start = vertices[0];
+ for (var i = 0; i < points.Length; i++)
+ {
+ var a = vertices[i] - start;
+ var b = vertices[i + 1] - start;
+ signedArea += a.X * b.Y - b.X * a.Y;
+ // The host has already translated by origin: graph, not control coordinates.
+ points[i] = view.PointWorldToGraph(vertices[i]);
+ }
+ if (signedArea < 0)
+ Array.Reverse(points);
+ next.AddPolygon(points);
+ }
+ }
+ path = next;
+ pathReport = state.Report;
+ pathScale = view.ViewScale;
+ }
+ catch
+ {
+ next.Dispose();
+ throw;
+ }
+ }
+
+ private void DrawStateLabel(Graphics graphics)
+ {
+ var saved = graphics.Save();
+ try
+ {
+ graphics.ResetTransform();
+ var text = state.Message + (state.DisplayMode == OverlapDisplayMode.Off ? " (display off)" : "");
+ var size = graphics.MeasureString(text, view.Font);
+ using var background = new SolidBrush(Color.FromArgb(235, Color.White));
+ graphics.FillRectangle(background, 6, 6, size.Width + 8, size.Height + 6);
+ graphics.DrawString(text, view.Font, Brushes.DarkRed, 10, 9);
+ }
+ finally
+ {
+ graphics.Restore(saved);
+ }
+ }
+
+ private void ReleasePath()
+ {
+ path?.Dispose();
+ path = null;
+ pathReport = null;
+ }
+
+ public void Dispose()
+ {
+ if (disposed)
+ return;
+ disposed = true;
+ Unsubscribe();
+ CancelWorker();
+ state.Reset();
+ ReleasePath();
+ }
+}
diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs
index 928e8ad..7fb35ac 100644
--- a/OpenNest/Controls/PlateView.cs
+++ b/OpenNest/Controls/PlateView.cs
@@ -10,6 +10,7 @@ using System.Threading.Tasks;
using System.Windows.Forms;
using OpenNest.Actions;
using OpenNest.Collections;
+using OpenNest.Diagnostics;
using OpenNest.Engine;
using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs.Placement;
@@ -23,6 +24,7 @@ namespace OpenNest.Controls
public class PlateView : DrawControl
{
private readonly Timer redrawTimer;
+ private readonly OverlapOverlayController overlapOverlay;
private string status;
private Plate plate;
@@ -86,6 +88,7 @@ namespace OpenNest.Controls
public PlateView(ColorScheme colorScheme)
{
+ overlapOverlay = new OverlapOverlayController(this);
Plate = new Plate(60, 120);
origin = new PointF();
parts = new List();
@@ -208,9 +211,28 @@ namespace OpenNest.Controls
foreach (var part in plate.Parts)
parts.Add(LayoutPart.Create(part, this));
+ overlapOverlay.SetPlate(plate);
actionManager?.OnPlateChanged();
}
+ internal OverlapOverlayController OverlapOverlay => overlapOverlay;
+ public OverlapCheckStatus OverlapStatus => overlapOverlay.Status;
+ public PlateOverlapReport OverlapReport => overlapOverlay.Report;
+ public bool IsOverlapCheckRunning => overlapOverlay.IsRunning;
+ public OverlapDisplayMode OverlapDisplay
+ {
+ get => overlapOverlay.DisplayMode;
+ set => overlapOverlay.DisplayMode = value;
+ }
+ public event EventHandler OverlapStateChanged
+ {
+ add => overlapOverlay.StateChanged += value;
+ remove => overlapOverlay.StateChanged -= value;
+ }
+ public Task CheckOverlapsAsync() => overlapOverlay.CheckAsync();
+ public void CancelOverlapCheck() => overlapOverlay.Cancel();
+ public void InvalidateOverlapCheck() => overlapOverlay.Invalidate();
+
public string Status
{
get { return status; }
@@ -491,6 +513,7 @@ namespace OpenNest.Controls
renderer.DrawCutOffs(e.Graphics);
renderer.DrawActiveWorkArea(e.Graphics);
renderer.DrawDebugRemnants(e.Graphics);
+ DrawOverlapOverlay(e.Graphics);
base.OnPaint(e);
@@ -515,12 +538,22 @@ namespace OpenNest.Controls
}
}
+ private void DrawOverlapOverlay(Graphics graphics) => overlapOverlay.Draw(graphics);
+
protected override void OnHandleDestroyed(EventArgs e)
{
+ overlapOverlay.ReleaseHandle();
base.OnHandleDestroyed(e);
actionManager.Cleanup();
}
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ overlapOverlay?.Dispose();
+ base.Dispose(disposing);
+ }
+
public override void Refresh()
{
parts.ForEach(p => p.Update(this));
diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs
index 769e402..c8f86f0 100644
--- a/OpenNest/Forms/EditNestForm.cs
+++ b/OpenNest/Forms/EditNestForm.cs
@@ -10,6 +10,7 @@ using OpenNest.Api;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Collections;
using OpenNest.Controls;
+using OpenNest.Diagnostics;
using OpenNest.Engine;
using OpenNest.Engine.Sequencing;
using OpenNest.IO;
@@ -26,6 +27,15 @@ namespace OpenNest.Forms
public readonly Document Document;
public readonly PlateView PlateView;
+
+ public System.Threading.Tasks.Task CheckOverlapsAsync() => PlateView.CheckOverlapsAsync();
+ public void CancelOverlapCheck() => PlateView.CancelOverlapCheck();
+ public OverlapDisplayMode OverlapDisplay
+ {
+ get => PlateView.OverlapDisplay;
+ set => PlateView.OverlapDisplay = value;
+ }
+
public readonly PlateManager PlateManager;
public Nest Nest => Document.Nest;
@@ -897,6 +907,8 @@ namespace OpenNest.Forms
program => NestWriter.GetProgramText(program) + "\0" + NestWriter.GetSubProgramsText(program)
);
var converter = new CadConverterForm();
+ // LoadDrawings can edit live programs even when the dialog is canceled.
+ PlateView.InvalidateOverlapCheck();
converter.LoadDrawings(Nest.Drawings);
if (converter.ShowDialog() != DialogResult.OK)
diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs
index b101cf9..3ff9260 100644
--- a/OpenNest/Forms/MainForm.Designer.cs
+++ b/OpenNest/Forms/MainForm.Designer.cs
@@ -48,6 +48,12 @@
toolStripMenuItem4 = new System.Windows.Forms.ToolStripSeparator();
mnuEditSelectAll = new System.Windows.Forms.ToolStripMenuItem();
mnuView = new System.Windows.Forms.ToolStripMenuItem();
+ mnuViewOverlapCheck = new System.Windows.Forms.ToolStripMenuItem();
+ mnuOverlapCheckActive = new System.Windows.Forms.ToolStripMenuItem();
+ mnuOverlapCancel = new System.Windows.Forms.ToolStripMenuItem();
+ mnuOverlapDisplay = new System.Windows.Forms.ToolStripMenuItem();
+ mnuOverlapOff = new System.Windows.Forms.ToolStripMenuItem();
+ mnuOverlapAreas = new System.Windows.Forms.ToolStripMenuItem();
mnuViewDrawRapids = new System.Windows.Forms.ToolStripMenuItem();
mnuViewDrawPiercePoints = new System.Windows.Forms.ToolStripMenuItem();
mnuViewDrawBounds = new System.Windows.Forms.ToolStripMenuItem();
@@ -318,10 +324,49 @@
//
// mnuView
//
- mnuView.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuViewDrawRapids, mnuViewDrawPiercePoints, mnuViewDrawBounds, mnuViewDrawOffset, mnuViewDrawCutDirection, toolStripMenuItem5, mnuViewZoomTo, mnuViewZoomIn, mnuViewZoomOut });
+ mnuView.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuViewDrawRapids, mnuViewDrawPiercePoints, mnuViewDrawBounds, mnuViewDrawOffset, mnuViewDrawCutDirection, mnuViewOverlapCheck, toolStripMenuItem5, mnuViewZoomTo, mnuViewZoomIn, mnuViewZoomOut });
mnuView.Name = "mnuView";
mnuView.Size = new System.Drawing.Size(44, 20);
mnuView.Text = "&View";
+ mnuView.DropDownOpening += OverlapMenu_Opening;
+ //
+ // mnuViewOverlapCheck
+ //
+ mnuViewOverlapCheck.Name = "mnuViewOverlapCheck";
+ mnuViewOverlapCheck.Text = "Overlap Check";
+ mnuViewOverlapCheck.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuOverlapCheckActive, mnuOverlapCancel, mnuOverlapDisplay });
+ mnuViewOverlapCheck.DropDownOpening += OverlapMenu_Opening;
+ //
+ // mnuOverlapCheckActive
+ //
+ mnuOverlapCheckActive.Name = "mnuOverlapCheckActive";
+ mnuOverlapCheckActive.Text = "Check Active Plate";
+ mnuOverlapCheckActive.Click += CheckOverlaps_Click;
+ //
+ // mnuOverlapCancel
+ //
+ mnuOverlapCancel.Name = "mnuOverlapCancel";
+ mnuOverlapCancel.Text = "Cancel Check";
+ mnuOverlapCancel.Click += CancelOverlapCheck_Click;
+ //
+ // mnuOverlapDisplay
+ //
+ mnuOverlapDisplay.Name = "mnuOverlapDisplay";
+ mnuOverlapDisplay.Text = "Display";
+ mnuOverlapDisplay.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuOverlapOff, mnuOverlapAreas });
+ mnuOverlapDisplay.DropDownOpening += OverlapMenu_Opening;
+ //
+ // mnuOverlapOff
+ //
+ mnuOverlapOff.Name = "mnuOverlapOff";
+ mnuOverlapOff.Text = "Off";
+ mnuOverlapOff.Click += OverlapOff_Click;
+ //
+ // mnuOverlapAreas
+ //
+ mnuOverlapAreas.Name = "mnuOverlapAreas";
+ mnuOverlapAreas.Text = "Areas";
+ mnuOverlapAreas.Click += OverlapAreas_Click;
//
// mnuViewDrawRapids
//
@@ -1241,6 +1286,12 @@
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem4;
private System.Windows.Forms.ToolStripMenuItem mnuEditSelectAll;
private System.Windows.Forms.ToolStripMenuItem mnuView;
+ private System.Windows.Forms.ToolStripMenuItem mnuViewOverlapCheck;
+ private System.Windows.Forms.ToolStripMenuItem mnuOverlapCheckActive;
+ private System.Windows.Forms.ToolStripMenuItem mnuOverlapCancel;
+ private System.Windows.Forms.ToolStripMenuItem mnuOverlapDisplay;
+ private System.Windows.Forms.ToolStripMenuItem mnuOverlapOff;
+ private System.Windows.Forms.ToolStripMenuItem mnuOverlapAreas;
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawRapids;
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawPiercePoints;
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawBounds;
diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs
index 38cb987..fb3736a 100644
--- a/OpenNest/Forms/MainForm.cs
+++ b/OpenNest/Forms/MainForm.cs
@@ -10,6 +10,7 @@ using System.Windows.Forms;
using OpenNest.Actions;
using OpenNest.Collections;
using OpenNest.Data;
+using OpenNest.Diagnostics;
using OpenNest.Engine;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Fill;
@@ -47,6 +48,7 @@ namespace OpenNest.Forms
public MainForm()
{
InitializeComponent();
+ UpdateOverlapMenu();
LoadSettings();
var renderer = new ToolStripRenderer(ToolbarTheme.Toolbar);
@@ -438,6 +440,7 @@ namespace OpenNest.Forms
activeForm.PlateView.MouseMove -= PlateView_MouseMove;
activeForm.PlateView.MouseClick -= PlateView_MouseClick;
activeForm.PlateView.StatusChanged -= PlateView_StatusChanged;
+ activeForm.PlateView.OverlapStateChanged -= OverlapStateChanged;
activeForm.PlateView.SelectionChanged -= PlateView_SelectionChanged;
activeForm.PlateView.PartAdded -= PlateView_PartAdded;
activeForm.PlateView.PartRemoved -= PlateView_PartRemoved;
@@ -450,6 +453,7 @@ namespace OpenNest.Forms
}
activeForm = ActiveMdiChild as EditNestForm;
+ UpdateOverlapMenu();
EnableCheck();
UpdatePlateStatus();
@@ -465,6 +469,7 @@ namespace OpenNest.Forms
UpdateLocationMode();
UpdateSelectionStatus();
activeForm.PlateView.StatusChanged += PlateView_StatusChanged;
+ activeForm.PlateView.OverlapStateChanged += OverlapStateChanged;
activeForm.PlateView.SelectionChanged += PlateView_SelectionChanged;
activeForm.PlateView.PartAdded += PlateView_PartAdded;
activeForm.PlateView.PartRemoved += PlateView_PartRemoved;
@@ -658,6 +663,43 @@ namespace OpenNest.Forms
#region View Menu Events
+ private void OverlapStateChanged(object sender, EventArgs e) => UpdateOverlapMenu();
+ private void OverlapMenu_Opening(object sender, EventArgs e) => UpdateOverlapMenu();
+
+ private void UpdateOverlapMenu()
+ {
+ var hasPlate = activeForm != null && !activeForm.IsDisposed && activeForm.PlateView.Plate != null;
+ mnuViewOverlapCheck.Enabled = hasPlate;
+ mnuOverlapCheckActive.Enabled = hasPlate;
+ mnuOverlapCancel.Enabled = hasPlate && activeForm.PlateView.IsOverlapCheckRunning;
+ mnuOverlapDisplay.Enabled = hasPlate;
+ mnuOverlapOff.Enabled = hasPlate;
+ mnuOverlapAreas.Enabled = hasPlate;
+ mnuOverlapOff.Checked = hasPlate && activeForm.OverlapDisplay == OverlapDisplayMode.Off;
+ mnuOverlapAreas.Checked = hasPlate && activeForm.OverlapDisplay == OverlapDisplayMode.Areas;
+ }
+
+ private async void CheckOverlaps_Click(object sender, EventArgs e)
+ {
+ var form = activeForm;
+ if (form != null)
+ await form.CheckOverlapsAsync();
+ }
+
+ private void CancelOverlapCheck_Click(object sender, EventArgs e) => activeForm?.CancelOverlapCheck();
+
+ private void OverlapOff_Click(object sender, EventArgs e)
+ {
+ if (activeForm != null)
+ activeForm.OverlapDisplay = OverlapDisplayMode.Off;
+ }
+
+ private void OverlapAreas_Click(object sender, EventArgs e)
+ {
+ if (activeForm != null)
+ activeForm.OverlapDisplay = OverlapDisplayMode.Areas;
+ }
+
private void ToggleDrawRapids_Click(object sender, EventArgs e)
{
if (activeForm == null)
diff --git a/OpenNest/Properties/AssemblyInfo.cs b/OpenNest/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..ba6e8c3
--- /dev/null
+++ b/OpenNest/Properties/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("OpenNest.WinForms.Tests")]
diff --git a/README.md b/README.md
index 1666e6b..d14130a 100644
--- a/README.md
+++ b/README.md
@@ -12,6 +12,7 @@ A Windows desktop application for CNC nesting — imports DXF drawings, arranges
- **Import / export** — DXF & DWG parts (ACadSharp), Excel BOMs, bend-line detection, built-in parametric shapes; export DXF or post-processed G-code.
- **Nesting** — pluggable whole-job engines (Default, Strip, Vertical/Horizontal Remnant, StockLadder, plus DLL plugins), NFP-based interlocking pair evaluation, gravity compaction, rotation sweeps, multi-plate/multi-material jobs.
- **Plate operations** — manual sheet cut-offs, [automatic scrap cutoffs with unused-tail preservation](docs/automatic-scrap-cutoffs.md), oversized-part splitting (straight, weld-gap tabs, spike-groove), interactive editing, and spacing-aware pushes that can slide along or away from touching parts.
+- **Visual overlap check** — manually highlight shared material on the active plate, including containment and cutouts, through View > Overlap Check. [Usage and limitations](docs/geometry/visual-overlap-check.md).
- **CNC output** — configurable lead-ins/outs and tabs, contour editing, user-defined G-code variables (`$name` → `#200+` machine variables), plugin post-processors (Cincinnati CL-707/800/900/940/CLX included).
## Requirements
diff --git a/docs/geometry/visual-overlap-check.md b/docs/geometry/visual-overlap-check.md
index bbf99e6..2e013e0 100644
--- a/docs/geometry/visual-overlap-check.md
+++ b/docs/geometry/visual-overlap-check.md
@@ -1,9 +1,30 @@
-# Material-overlap polygon diagnostics
+# Visual material-overlap check
+
+## Desktop use
+
+Choose **View > Overlap Check > Check Active Plate** to check committed parts on
+this plate. Shared material is shaded red/magenta without changing the nest,
+selection, cutting paths, or export behavior. Cutoffs and temporary preview parts
+are excluded. **Cancel Check** discards the running request. **Display > Off /
+Areas** changes visibility without rerunning analysis; checking from Off shows
+Areas. Display preferences belong to the current document and are not saved.
+
+A persistent label distinguishes unchecked, checking, current, incomplete, stale,
+canceled, and failed checks. Only a completed, current, fully checked report can
+say **No material overlaps detected**. An incomplete check retains known overlaps
+and states how many distinct parts could not be checked. Pair counts are not
+fragment counts. This diagnostic checks shared material, not minimum spacing,
+plate edges, or cutting-path crossings.
+
+Edits clear the overlay and require another explicit check. Plate changes reset
+the check. Pan, zoom, selection, and display changes do not rerun geometry.
+Drawing-editor loading invalidates before loading, even if the dialog is later
+canceled. There is no automatic check during dragging or export.
+
+## Analysis API
`OpenNest.Diagnostics.PlateOverlapAnalyzer` in OpenNest.Core checks a group of placed
-parts and returns the shared polygon areas for each overlapping pair. This is the
-cross-platform analysis foundation for a future PlateView overlay; it does not add
-a menu command, painting, centroids, or freshness management yet. Existing
+parts and returns the shared polygon areas for each overlapping pair. Existing
`Part.Intersects`, `PartOverlapChecker`, `Plate.HasOverlappingParts`, engine
validators, and CLI entry points are unchanged. A separate shared-triangulator fix
uses translation-stable winding, correcting missed clockwise outlines/holes far
@@ -121,10 +142,33 @@ slivers are below its reporting policy. Floating-point coordinates still have
finite resolution. Contact and fragment thresholds are unchanged; only the shared
triangulator's winding arithmetic was stabilized in the prerequisite fix.
-Next integration/hardening: add PlateView request generations and stale-result
-invalidation before rendering; measure real-plate capture/analysis cost and
-cancellation latency before adding cached triangulations or background capture;
-add area-weighted centroids separately if the UI needs them.
+## Desktop lifecycle and rendering
+
+`OverlapReportState` and `OverlapGeometryStamp` in Core hold the testable request
+policy. The stamp compares ordered part identities, exact pose scalars, drawing
+and program references, cutoff status, and plate identity. It is not a geometry
+hash: any new editor that mutates a clean program in place must call
+`PlateView.InvalidateOverlapCheck()` before loading/mutation. Current live clean
+program editing goes through `EditNestForm.EditDrawingsInConverter_Click`;
+metadata-only edits do not change material. In-place hole-program edits require
+the same explicit invalidation.
+
+`OverlapOverlayController` owns UI-thread captures, background analysis, request
+generations, cancellation, and the GDI display cache. It checks freshness before
+publication and painting. Handle destruction/disposal cancels work and releases
+paths; old completions cannot replace a newer report. Snapshot conversion and
+clipping never run in paint or mouse-move handlers.
+
+PlateView draws the controller overlay after work-area/debug-remnant drawing and
+before action paint subscribers and hover tooltips. One consistently wound path
+is filled once, avoiding fragment outlines, internal triangulation seams, and
+darker triple coverage. World-to-graph conversion excludes pan, because PlateView
+already applies origin translation. Paths are rebuilt for report/scale changes,
+not ordinary repaints or panning. The state label saves/restores graphics state.
+
+Next hardening: measure real-plate capture/analysis cost and cancellation latency
+before adding cached triangulations or background capture. Cancellation cannot
+interrupt the interior of an existing kernel operation.
## Verification
@@ -136,5 +180,21 @@ inputs, snapshot isolation, read-only output, cutting-program independence, and
cancellation. Run:
```sh
-dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter FullyQualifiedName~PlateOverlapAnalyzerTests
+dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~PlateOverlapAnalyzerTests|FullyQualifiedName~OverlapReportStateTests'
```
+
+`OverlapReportStateTests` verifies request supersession, exact pose/reference
+freshness, stale clearing, cancellation, and incomplete-versus-clear messaging.
+`OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs` adds STA worker/publication,
+menu/MDI, path-cache, uniform-fill pixel, and control-lifetime checks. Run those
+on Windows:
+
+```sh
+dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj
+```
+
+Linux can cross-build with `-p:EnableWindowsTargeting=true`, but that does not
+execute Windows tests or verify appearance, DPI, or interaction. On Windows,
+check partial overlap, containment, inside-hole placement, pan/zoom and quadrant
+alignment, stale clearing during edits/plate switches, converter cancellation,
+and repeated check/toggle/close cycles without GDI/disposed-control errors.