diff --git a/OpenNest.Core/Diagnostics/OverlapAutoCheckScheduler.cs b/OpenNest.Core/Diagnostics/OverlapAutoCheckScheduler.cs
new file mode 100644
index 0000000..86ecd84
--- /dev/null
+++ b/OpenNest.Core/Diagnostics/OverlapAutoCheckScheduler.cs
@@ -0,0 +1,80 @@
+namespace OpenNest.Diagnostics;
+
+public enum OverlapAutoCheckStep { None, Wait, Check }
+
+///
+/// UI-thread debounce policy for automatic overlap rechecks; the host owns the timer.
+/// Restart the quiet-period timer whenever returns true, and call
+/// when it fires. A check starts only after the ordered layout stamp
+/// has stayed unchanged for a whole quiet period with no interaction in progress.
+/// A canceled or failed request is not retried until the layout differs from the one it
+/// started from, so cancellation is respected and a failing layout cannot loop.
+///
+public sealed class OverlapAutoCheckScheduler
+{
+ private OverlapGeometryStamp pending;
+ private OverlapGeometryStamp lastRequest;
+
+ public bool IsWaiting => pending != null;
+
+ ///
+ /// Call after the report state's freshness check. True means (re)start the quiet-period
+ /// timer; false means leave it as it is.
+ ///
+ public bool Observe(Plate plate, OverlapReportState state)
+ {
+ if (plate == null || !NeedsCheck(plate, state))
+ {
+ pending = null;
+ return false;
+ }
+ if (pending != null && pending.Matches(plate))
+ return false;
+ pending = OverlapGeometryStamp.Capture(plate);
+ return true;
+ }
+
+ ///
+ /// Call when the quiet period ends. means restart the
+ /// timer (the layout moved or an interaction is still running); only
+ /// starts a request.
+ ///
+ public OverlapAutoCheckStep Elapsed(Plate plate, OverlapReportState state, bool interactionActive)
+ {
+ if (pending == null)
+ return OverlapAutoCheckStep.None;
+ if (plate == null || !NeedsCheck(plate, state))
+ {
+ pending = null;
+ return OverlapAutoCheckStep.None;
+ }
+ if (interactionActive || !pending.Matches(plate))
+ {
+ pending = OverlapGeometryStamp.Capture(plate);
+ return OverlapAutoCheckStep.Wait;
+ }
+ pending = null;
+ return OverlapAutoCheckStep.Check;
+ }
+
+ /// Record every request start, manual or automatic.
+ public void Started(Plate plate)
+ {
+ pending = null;
+ lastRequest = OverlapGeometryStamp.Capture(plate);
+ }
+
+ /// Forget pending and previous requests (plate switch, handle loss, disable).
+ public void Reset()
+ {
+ pending = null;
+ lastRequest = null;
+ }
+
+ private bool NeedsCheck(Plate plate, OverlapReportState state) => state.Status switch
+ {
+ OverlapCheckStatus.NotChecked or OverlapCheckStatus.Stale => true,
+ OverlapCheckStatus.Canceled or OverlapCheckStatus.Failed => lastRequest?.Matches(plate) != true,
+ _ => false
+ };
+}
diff --git a/OpenNest.Core/Diagnostics/OverlapReportState.cs b/OpenNest.Core/Diagnostics/OverlapReportState.cs
index efcc15f..ff66dcd 100644
--- a/OpenNest.Core/Diagnostics/OverlapReportState.cs
+++ b/OpenNest.Core/Diagnostics/OverlapReportState.cs
@@ -30,11 +30,15 @@ public sealed class OverlapReportState
_ => "Overlaps: not checked"
};
- public long Begin(Plate plate)
+ ///
+ /// Starts a request. A manual check from Off shows Areas; an automatic recheck keeps
+ /// the user's display choice, including Off.
+ ///
+ public long Begin(Plate plate, bool automatic = false)
{
Clear(OverlapCheckStatus.Checking);
stamp = OverlapGeometryStamp.Capture(plate);
- if (DisplayMode == OverlapDisplayMode.Off)
+ if (!automatic && DisplayMode == OverlapDisplayMode.Off)
DisplayMode = OverlapDisplayMode.Areas;
return Generation;
}
diff --git a/OpenNest.Tests/Diagnostics/OverlapAutoCheckSchedulerTests.cs b/OpenNest.Tests/Diagnostics/OverlapAutoCheckSchedulerTests.cs
new file mode 100644
index 0000000..d15e286
--- /dev/null
+++ b/OpenNest.Tests/Diagnostics/OverlapAutoCheckSchedulerTests.cs
@@ -0,0 +1,114 @@
+using OpenNest.CNC;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.Diagnostics;
+
+public class OverlapAutoCheckSchedulerTests
+{
+ [Fact]
+ public void ChecksOnlyAfterTheLayoutStaysQuietForAWholePeriod()
+ {
+ var plate = PlateWithParts();
+ var state = new OverlapReportState();
+ var scheduler = new OverlapAutoCheckScheduler();
+
+ Assert.True(scheduler.Observe(plate, state)); // NotChecked: start the timer
+ Assert.False(scheduler.Observe(plate, state)); // repaint without change: leave it running
+ plate.Parts[0].Offset(1, 0); // drag step
+ Assert.True(scheduler.Observe(plate, state)); // restart the quiet period
+ plate.Parts[0].Offset(1, 0); // moved again, not yet observed
+ Assert.Equal(OverlapAutoCheckStep.Wait, scheduler.Elapsed(plate, state, interactionActive: false));
+ Assert.Equal(OverlapAutoCheckStep.Check, scheduler.Elapsed(plate, state, interactionActive: false));
+ Assert.False(scheduler.IsWaiting);
+ }
+
+ [Fact]
+ public void WaitsWhileAnInteractionIsActive()
+ {
+ var plate = PlateWithParts();
+ var state = new OverlapReportState();
+ var scheduler = new OverlapAutoCheckScheduler();
+ scheduler.Observe(plate, state);
+ Assert.Equal(OverlapAutoCheckStep.Wait, scheduler.Elapsed(plate, state, interactionActive: true));
+ Assert.Equal(OverlapAutoCheckStep.Check, scheduler.Elapsed(plate, state, interactionActive: false));
+ }
+
+ [Fact]
+ public void CurrentOrRunningChecksNeedNoAutomaticRequest()
+ {
+ var plate = PlateWithParts();
+ var state = new OverlapReportState();
+ var scheduler = new OverlapAutoCheckScheduler();
+ scheduler.Observe(plate, state);
+ var request = state.Begin(plate);
+ scheduler.Started(plate);
+ Assert.False(scheduler.Observe(plate, state));
+ Assert.Equal(OverlapAutoCheckStep.None, scheduler.Elapsed(plate, state, false));
+ Assert.True(state.TryPublish(request, plate, PlateOverlapAnalyzer.Analyze(plate.Parts.ToArray())));
+ Assert.False(scheduler.Observe(plate, state));
+
+ plate.Parts[1].Offset(0, 3);
+ state.EnsureFresh(plate); // Stale after the edit
+ Assert.True(scheduler.Observe(plate, state));
+ Assert.Equal(OverlapAutoCheckStep.Check, scheduler.Elapsed(plate, state, false));
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void CanceledOrFailedLayoutIsNotRetriedUntilItChanges(bool cancel)
+ {
+ var plate = PlateWithParts();
+ var state = new OverlapReportState();
+ var scheduler = new OverlapAutoCheckScheduler();
+ var request = state.Begin(plate);
+ scheduler.Started(plate);
+ if (cancel)
+ state.Cancel();
+ else
+ Assert.True(state.TryFail(request, plate));
+
+ Assert.False(scheduler.Observe(plate, state)); // respect the cancel / avoid a failure loop
+ Assert.Equal(OverlapAutoCheckStep.None, scheduler.Elapsed(plate, state, false));
+ plate.Parts[0].Offset(0.5, 0);
+ Assert.True(scheduler.Observe(plate, state));
+ Assert.Equal(OverlapAutoCheckStep.Check, scheduler.Elapsed(plate, state, false));
+ }
+
+ [Fact]
+ public void ResetForgetsPendingWorkAndPreviousRequests()
+ {
+ var plate = PlateWithParts();
+ var state = new OverlapReportState();
+ var scheduler = new OverlapAutoCheckScheduler();
+ state.Begin(plate);
+ scheduler.Started(plate);
+ state.Cancel();
+ scheduler.Reset();
+ Assert.True(scheduler.Observe(plate, state)); // a new document/plate may check again
+ scheduler.Reset();
+ Assert.False(scheduler.IsWaiting);
+ Assert.Equal(OverlapAutoCheckStep.None, scheduler.Elapsed(plate, state, false));
+ Assert.False(scheduler.Observe(null, state));
+ }
+
+ 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.Tests/Diagnostics/OverlapReportStateTests.cs b/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs
index 8386a15..a282516 100644
--- a/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs
+++ b/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs
@@ -150,6 +150,16 @@ public class OverlapReportStateTests
Assert.Equal(expected, state.DisplayMode);
}
+ [Fact]
+ public void AutomaticCheckKeepsDisplayOff()
+ {
+ var plate = PlateWithParts();
+ var state = new OverlapReportState { DisplayMode = OverlapDisplayMode.Off };
+ Assert.True(state.TryPublish(state.Begin(plate, automatic: true), plate, Analyze(plate)));
+ Assert.Equal(OverlapDisplayMode.Off, state.DisplayMode);
+ Assert.Single(state.Report.Pairs);
+ }
+
[Fact]
public void NewPlateResetsStateButRetainsDocumentDisplayPreference()
{
diff --git a/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs
index b9d668f..2ac0b84 100644
--- a/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs
+++ b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs
@@ -277,12 +277,15 @@ public class PlateOverlapOverlayTests
Assert.All(new[] { check, cancel, off, areas, centroids, both }, item => Assert.Equal(Keys.None, item.ShortcutKeys));
using var first = new EditNestForm(new Nest("first")) { MdiParent = host };
+ // Manual-command test: an auto-check timer pumped during a wait would race its requests.
+ first.PlateView.SetOverlapAutoCheck(null);
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.PlateView.SetOverlapAutoCheck(null);
second.Show();
Assert.True(areas.Checked);
Assert.False(off.Checked);
@@ -341,6 +344,7 @@ public class PlateOverlapOverlayTests
public void CheckCommandCapturesUnitsAndNamesUntilTheNextCheck() => RunSta(() =>
{
using var form = new EditNestForm(new Nest("units") { Units = Units.Millimeters });
+ form.PlateView.SetOverlapAutoCheck(null); // Manual-command test; see the menu test.
form.Show();
form.PlateView.Plate.Parts.Add(Rectangle());
form.PlateView.Plate.Parts.Add(Rectangle(1));
@@ -766,6 +770,142 @@ public class PlateOverlapOverlayTests
Assert.False(run.View.Command(Keys.PageDown));
});
+ [Fact]
+ public void AutoCheckRunsOnceTheLayoutSettlesAndRechecksAfterADrag() => RunSta(() =>
+ {
+ using var run = new OverlayRun();
+ run.View.Status = "Fill: 2 parts in 5 ms";
+ run.EnableAutoCheck();
+ var overlay = run.View.OverlapOverlay;
+ Assert.True(overlay.IsAutoCheckScheduled);
+ Assert.Equal("Overlaps: check pending…", overlay.Message);
+
+ var task = run.AutoTick();
+ run.Next().Complete();
+ run.Pump(task);
+ Assert.Equal(OverlapCheckStatus.Current, run.View.OverlapStatus);
+ Assert.False(overlay.IsAutoCheckScheduled);
+ Assert.Equal(1, run.Calls);
+ Assert.Equal("Fill: 2 parts in 5 ms", run.View.Status); // canvas label only
+ Assert.Equal("Overlaps: 1 pairs", overlay.Message);
+
+ // A drag changes only Part.Location: no collection event, so paint must notice it.
+ run.View.Plate.Parts[1].Offset(0.5, 0);
+ run.Paint();
+ Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus);
+ Assert.True(overlay.IsAutoCheckScheduled);
+ run.View.Plate.Parts[1].Offset(0.5, 0); // still moving when the timer fires
+ Assert.Same(Task.CompletedTask, run.AutoTick());
+ Assert.True(overlay.IsAutoCheckScheduled); // waited another quiet period
+ Assert.Equal(1, run.Calls);
+ task = run.AutoTick();
+ run.Next().Complete();
+ run.Pump(task);
+ Assert.Equal(OverlapCheckStatus.Current, run.View.OverlapStatus);
+ Assert.Equal(2, run.Calls);
+ });
+
+ [Fact]
+ public void AutoCheckWaitsForInteractionsAndRespectsCancel() => RunSta(() =>
+ {
+ using var run = new OverlayRun();
+ var busy = true;
+ run.EnableAutoCheck(() => busy);
+ Assert.Same(Task.CompletedTask, run.AutoTick());
+ Assert.True(run.View.OverlapOverlay.IsAutoCheckScheduled);
+ Assert.Equal(0, run.Calls);
+ busy = false;
+ var task = run.AutoTick();
+ var work = run.Next();
+ run.View.CancelOverlapCheck();
+ work.Complete();
+ run.Pump(task);
+ Assert.Equal(OverlapCheckStatus.Canceled, run.View.OverlapStatus);
+ run.Paint();
+ Assert.False(run.View.OverlapOverlay.IsAutoCheckScheduled); // no retry of a canceled layout
+
+ run.View.Plate.Parts.Add(Rectangle(2)); // but a new edit rechecks
+ Assert.True(run.View.OverlapOverlay.IsAutoCheckScheduled);
+ task = run.AutoTick();
+ run.Next().Complete();
+ run.Pump(task);
+ Assert.Equal(OverlapCheckStatus.Current, run.View.OverlapStatus);
+ Assert.Equal(3, run.View.OverlapReport.Pairs.Count);
+ });
+
+ [Fact]
+ public void AutoCheckSupersedesARunningCheckWhenTheLayoutChanges() => RunSta(() =>
+ {
+ using var run = new OverlayRun();
+ run.EnableAutoCheck();
+ var first = run.AutoTick();
+ var old = run.Next();
+ run.View.Plate.Parts[0].Offset(-10, 0); // user keeps editing while the check runs
+ run.Paint();
+ Assert.True(old.Token.IsCancellationRequested);
+ Assert.True(run.View.OverlapOverlay.IsAutoCheckScheduled);
+ old.Complete();
+ run.Pump(first);
+ Assert.Null(run.View.OverlapReport); // the old layout's result is never shown
+ var second = run.AutoTick();
+ run.Next().Complete();
+ run.Pump(second);
+ Assert.Equal(OverlapCheckStatus.Current, run.View.OverlapStatus);
+ Assert.Empty(run.View.OverlapReport!.Pairs);
+ });
+
+ [Fact]
+ public void AutoCheckKeepsDisplayOffButManualCheckStillShowsAreasAndReportsToStatusBar() => RunSta(() =>
+ {
+ using var run = new OverlayRun();
+ run.EnableAutoCheck();
+ run.View.OverlapDisplay = OverlapDisplayMode.Off;
+ var task = run.AutoTick();
+ run.Next().Complete();
+ run.Pump(task);
+ Assert.Equal(OverlapCheckStatus.Current, run.View.OverlapStatus);
+ Assert.Equal(OverlapDisplayMode.Off, run.View.OverlapDisplay);
+
+ run.View.Plate.Parts[1].Offset(0.25, 0);
+ task = run.Start(); // manual Check Active Plate
+ run.Next().Complete();
+ run.Pump(task);
+ Assert.Equal(OverlapDisplayMode.Areas, run.View.OverlapDisplay);
+ Assert.Equal("Overlaps: 1 pairs", run.View.Status);
+ Assert.False(run.View.OverlapOverlay.IsAutoCheckScheduled);
+ });
+
+ [Fact]
+ public void DefaultAnalyzerRechecksIncrementallyAndEditorsDropTheCache() => RunSta(() =>
+ {
+ using var run = new OverlayRun();
+ run.View.OverlapOverlay.Analyze = null; // production path: incremental real analyzer
+ run.View.Plate.Parts.Add(Rectangle(20));
+ run.View.Plate.Parts.Add(Rectangle(21));
+ var task = run.Start();
+ run.Pump(task);
+ var untouched = run.View.OverlapReport.Pairs.Single(pair => pair.PartAId == 2);
+ run.View.Plate.Parts[1].Offset(1, 0);
+ task = run.Start();
+ run.Pump(task);
+ Assert.Same(untouched.Regions, run.View.OverlapReport.Pairs.Single(pair => pair.PartAId == 2).Regions);
+ Assert.Equal(8, run.View.OverlapReport.Pairs.Single(pair => pair.PartAId == 0).Area, 9);
+
+ run.View.InvalidateOverlapCheck(); // converter may edit programs in place
+ task = run.Start();
+ run.Pump(task);
+ Assert.NotSame(untouched.Regions, run.View.OverlapReport.Pairs.Single(pair => pair.PartAId == 2).Regions);
+ });
+
+ [Fact]
+ public void EditNestFormEnablesAutoCheck() => RunSta(() =>
+ {
+ using var form = new EditNestForm(new Nest("auto"));
+ Assert.True(form.PlateView.OverlapOverlay.IsAutoCheckEnabled);
+ using var standalone = new PlateView();
+ Assert.False(standalone.OverlapOverlay.IsAutoCheckEnabled);
+ });
+
private static void PrepareCrowdedHover(OverlayRun run)
{
run.View.Size = new System.Drawing.Size(260, 160);
@@ -866,6 +1006,23 @@ public class PlateOverlapOverlayTests
return result!;
}
public void Pump(Task task) => context.Pump(task);
+ public Task AutoTick()
+ {
+ var task = View.OverlapOverlay.RunAutoCheckTimer();
+ tasks.Add(task);
+ return task;
+ }
+ public void EnableAutoCheck(Func? busy = null)
+ {
+ View.OverlapOverlay.IsInteractionActive = busy ?? (() => false);
+ View.SetOverlapAutoCheck(() => Units.Inches);
+ }
+ public void Paint()
+ {
+ using var image = new Bitmap(64, 64);
+ using var graphics = Graphics.FromImage(image);
+ View.OverlapOverlay.Draw(graphics);
+ }
public void Finish()
{
var task = Start();
diff --git a/OpenNest/Controls/OverlapOverlayController.cs b/OpenNest/Controls/OverlapOverlayController.cs
index e858d7f..feb1076 100644
--- a/OpenNest/Controls/OverlapOverlayController.cs
+++ b/OpenNest/Controls/OverlapOverlayController.cs
@@ -8,18 +8,28 @@ using System.Threading;
using System.Threading.Tasks;
using OpenNest.Collections;
using OpenNest.Diagnostics;
+using OpenNest.Forms;
using OpenNest.Geometry;
namespace OpenNest.Controls;
///
-/// UI-thread owner of manual overlap requests and their cached display geometry.
+/// UI-thread owner of overlap requests and their cached display geometry.
/// Workers see only owned analyzer snapshots, never the live plate or view.
+/// When auto-check is enabled, an unchecked or out-of-date layout is rechecked after it
+/// has been quiet for (see ).
///
internal sealed class OverlapOverlayController : IDisposable
{
private readonly PlateView view;
private readonly OverlapReportState state = new();
+ private readonly OverlapAutoCheckScheduler autoCheck = new();
+ // Prepared drawing material survives rechecks; only in-place program edits clear it.
+ private readonly OverlapMaterialCache materialCache = new();
+ // Last completed report: pairs of parts unchanged since then are reused, not clipped again.
+ private PlateOverlapReport baseline;
+ private readonly System.Windows.Forms.Timer autoCheckTimer;
+ private Func autoCheckUnits;
private ObservableList observedParts;
private CancellationTokenSource cancellation;
private GraphicsPath path;
@@ -33,7 +43,14 @@ internal sealed class OverlapOverlayController : IDisposable
private (float Scale, PointF Offset, int Dpi) hoverTransform;
private bool disposed;
- public OverlapOverlayController(PlateView view) => this.view = view;
+ public OverlapOverlayController(PlateView view)
+ {
+ this.view = view;
+ autoCheckTimer = new System.Windows.Forms.Timer { Interval = DefaultAutoCheckDelayMs };
+ autoCheckTimer.Tick += AutoCheckTick;
+ }
+
+ public const int DefaultAutoCheckDelayMs = 500;
public event EventHandler StateChanged;
public OverlapCheckStatus Status => state.Status;
@@ -41,11 +58,37 @@ internal sealed class OverlapOverlayController : IDisposable
public bool IsRunning => state.IsRunning;
// Deterministic worker seam for STA lifecycle tests. Capture always stays on the UI thread.
+ // Null runs the incremental analyzer against the last completed report.
internal Func Analyze { get; set; }
- = PlateOverlapAnalyzer.Analyze;
internal GraphicsPath CachedPath => path;
internal OverlapHoverPages HoverPages => hoverPages;
internal RectangleF HoverBounds { get; private set; }
+ internal bool IsAutoCheckEnabled => autoCheckUnits != null;
+ internal bool IsAutoCheckScheduled => autoCheckTimer.Enabled;
+ // Test seam for gestures that cannot be simulated off a real message loop.
+ internal Func IsInteractionActive { get; set; } = DefaultInteractionActive;
+
+ public int AutoCheckDelay
+ {
+ get => autoCheckTimer.Interval;
+ set => autoCheckTimer.Interval = value;
+ }
+
+ public string Message => autoCheck.IsWaiting
+ ? "Overlaps: check pending…"
+ : state.Message;
+
+ ///
+ /// Recheck automatically after layout edits settle. Units are read when each request
+ /// starts, like the manual command. Pass null to return to manual-only checks.
+ ///
+ public void SetAutoCheck(Func units)
+ {
+ autoCheckUnits = units;
+ autoCheck.Reset();
+ autoCheckTimer.Stop();
+ NotifyChanged();
+ }
public OverlapDisplayMode DisplayMode
{
@@ -64,6 +107,8 @@ internal sealed class OverlapOverlayController : IDisposable
Unsubscribe();
CancelWorker();
state.Reset();
+ autoCheck.Reset();
+ baseline = null;
ReleasePath();
observedParts = plate?.Parts;
if (observedParts != null)
@@ -75,7 +120,11 @@ internal sealed class OverlapOverlayController : IDisposable
NotifyChanged();
}
- public async Task CheckAsync(Units units)
+ public Task CheckAsync(Units units) => CheckAsync(units, automatic: false);
+
+ // Automatic requests keep the display mode and report only on the canvas label; the
+ // status bar keeps the user's last command result (for example a fill's timing).
+ private async Task CheckAsync(Units units, bool automatic)
{
if (!CanUseView || view.Plate == null)
return;
@@ -84,33 +133,37 @@ internal sealed class OverlapOverlayController : IDisposable
CancelWorker();
var plate = view.Plate;
- var generation = state.Begin(plate);
+ var generation = state.Begin(plate, automatic);
+ autoCheck.Started(plate);
capturedUnits = units; // Same request boundary as the owned geometry/names, never read live units in paint.
var source = new CancellationTokenSource();
cancellation = source;
var token = source.Token;
ReleasePath();
- NotifyChanged();
+ NotifyChanged(toStatusBar: !automatic);
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 snapshot = PlateOverlapAnalyzer.Capture(plate.Parts.ToArray(), materialCache, token);
+ var previous = baseline;
+ var analyze = Analyze ?? ((input, cancel) => PlateOverlapAnalyzer.Analyze(input, previous, cancel));
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))
+ else if (state.TryPublish(generation, view.Plate, report))
+ baseline = report;
+ else
CancelWorker(); // A pose/reference mismatch discovered at publication is stale.
- NotifyChanged();
+ NotifyChanged(toStatusBar: !automatic);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
if (CanUseView && generation == state.Generation)
{
state.Cancel();
- NotifyChanged();
+ NotifyChanged(toStatusBar: !automatic);
}
}
catch (Exception ex)
@@ -120,7 +173,7 @@ internal sealed class OverlapOverlayController : IDisposable
{
if (!state.TryFail(generation, view.Plate))
CancelWorker();
- NotifyChanged();
+ NotifyChanged(toStatusBar: !automatic);
}
}
finally
@@ -140,7 +193,18 @@ internal sealed class OverlapOverlayController : IDisposable
CancelWorker();
state.Cancel();
ReleasePath();
- NotifyChanged();
+ NotifyChanged(toStatusBar: true); // The user's Cancel command.
+ }
+
+ ///
+ /// For editors that may mutate clean drawing programs in place: drops cached material
+ /// as well as the current report. Call before loading the editor.
+ ///
+ public void InvalidateGeometry()
+ {
+ materialCache.Clear();
+ baseline = null;
+ Invalidate();
}
public void Invalidate()
@@ -148,7 +212,16 @@ internal sealed class OverlapOverlayController : IDisposable
var generation = state.Generation;
state.Invalidate();
if (generation == state.Generation)
+ {
+ // Already unchecked/stale: an edit restarts the quiet period. Bulk fills raise one
+ // event per part, so only restart the timer here; the next paint or the timer's own
+ // settle check refreshes the layout stamp.
+ if (autoCheck.IsWaiting)
+ RestartAutoCheckTimer();
+ else
+ UpdateAutoCheck(repaint: true);
return;
+ }
CancelWorker();
ReleasePath();
NotifyChanged();
@@ -160,6 +233,10 @@ internal sealed class OverlapOverlayController : IDisposable
state.Cancel();
state.Invalidate();
ReleasePath();
+ // Handle loss is not a user cancel: forget that request so the first paint after
+ // recreation can schedule a recheck. A tick while the handle is gone only resets.
+ autoCheck.Reset();
+ autoCheckTimer.Stop();
NotifyChanged();
}
@@ -171,16 +248,79 @@ internal sealed class OverlapOverlayController : IDisposable
source?.Cancel();
}
- private void NotifyChanged()
+ private void NotifyChanged(bool toStatusBar = false)
{
ClearHover();
if (disposed || view.IsDisposed || view.Disposing)
return;
- view.Status = state.Message;
+ UpdateAutoCheck(repaint: false);
+ // Without auto-check every change is reported as before.
+ if (toStatusBar || autoCheckUnits == null)
+ view.Status = Message;
view.Invalidate();
StateChanged?.Invoke(this, EventArgs.Empty);
}
+ ///
+ /// Restart the quiet period when the layout changed since it was last observed.
+ /// redraws the label if its pending text changed; paint and
+ /// NotifyChanged callers draw or invalidate themselves.
+ ///
+ private void UpdateAutoCheck(bool repaint)
+ {
+ if (autoCheckUnits == null || disposed)
+ return;
+ var wasWaiting = autoCheck.IsWaiting;
+ if (autoCheck.Observe(view.Plate, state))
+ RestartAutoCheckTimer();
+ else if (!autoCheck.IsWaiting)
+ autoCheckTimer.Stop();
+ if (repaint && wasWaiting != autoCheck.IsWaiting && !view.IsDisposed && !view.Disposing)
+ view.Invalidate();
+ }
+
+ private void RestartAutoCheckTimer()
+ {
+ autoCheckTimer.Stop();
+ autoCheckTimer.Start();
+ }
+
+ private void AutoCheckTick(object sender, EventArgs e) => RunAutoCheckTimer();
+
+ /// The timer's tick. Returns the started request, if any, for STA tests.
+ internal Task RunAutoCheckTimer()
+ {
+ autoCheckTimer.Stop();
+ if (autoCheckUnits == null || disposed)
+ return Task.CompletedTask;
+ if (!CanUseView)
+ {
+ autoCheck.Reset();
+ return Task.CompletedTask;
+ }
+ var busy = view.IsFillInProgress || IsInteractionActive();
+ switch (autoCheck.Elapsed(view.Plate, state, busy))
+ {
+ case OverlapAutoCheckStep.Wait:
+ autoCheckTimer.Start();
+ return Task.CompletedTask;
+ case OverlapAutoCheckStep.Check:
+ // CheckAsync reports its own failures through the state label.
+ return CheckAsync(autoCheckUnits(), automatic: true);
+ default:
+ view.Invalidate(); // Pending text may have cleared.
+ return Task.CompletedTask;
+ }
+ }
+
+ // Do not check mid-gesture or while another operation owns the plate: dragging holds a
+ // mouse button, fills/auto-nest show a progress window, and modal editors (for example
+ // the drawing converter) may be mutating clean programs in place.
+ private static bool DefaultInteractionActive() =>
+ System.Windows.Forms.Control.MouseButtons != System.Windows.Forms.MouseButtons.None
+ || System.Windows.Forms.Application.OpenForms.Cast()
+ .Any(form => form.Modal || form is NestProgressForm);
+
private void PartAdded(object sender, ItemAddedEventArgs e) => Invalidate();
private void PartRemoved(object sender, ItemRemovedEventArgs e) => Invalidate();
private void PartChanged(object sender, ItemChangedEventArgs e) => Invalidate();
@@ -200,6 +340,7 @@ internal sealed class OverlapOverlayController : IDisposable
if (disposed)
return;
EnsureFresh();
+ UpdateAutoCheck(repaint: false); // Drags/nudges are only visible to the stamp; the label is drawn below.
ValidateHoverTransform();
if (state.Report != null)
@@ -435,7 +576,7 @@ internal sealed class OverlapOverlayController : IDisposable
try
{
graphics.ResetTransform();
- var text = state.Message + (state.DisplayMode == OverlapDisplayMode.Off ? " (display off)" : "");
+ var text = 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);
@@ -459,6 +600,8 @@ internal sealed class OverlapOverlayController : IDisposable
if (disposed)
return;
disposed = true;
+ autoCheckTimer.Stop();
+ autoCheckTimer.Dispose();
Unsubscribe();
CancelWorker();
state.Reset();
diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs
index 4d522ea..c3c01ef 100644
--- a/OpenNest/Controls/PlateView.cs
+++ b/OpenNest/Controls/PlateView.cs
@@ -232,7 +232,23 @@ namespace OpenNest.Controls
}
public Task CheckOverlapsAsync(Units units) => overlapOverlay.CheckAsync(units);
public void CancelOverlapCheck() => overlapOverlay.Cancel();
- public void InvalidateOverlapCheck() => overlapOverlay.Invalidate();
+ ///
+ /// Call before any editor that may mutate clean drawing programs in place: marks the
+ /// report out of date and drops cached drawing material.
+ ///
+ public void InvalidateOverlapCheck() => overlapOverlay.InvalidateGeometry();
+
+ ///
+ /// Recheck overlaps automatically once layout edits have been quiet for
+ /// ms. Null restores manual-only checks.
+ ///
+ public void SetOverlapAutoCheck(Func units) => overlapOverlay.SetAutoCheck(units);
+
+ public int OverlapAutoCheckDelay
+ {
+ get => overlapOverlay.AutoCheckDelay;
+ set => overlapOverlay.AutoCheckDelay = value;
+ }
public string Status
{
diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs
index 4d57473..951008a 100644
--- a/OpenNest/Forms/EditNestForm.cs
+++ b/OpenNest/Forms/EditNestForm.cs
@@ -265,6 +265,8 @@ namespace OpenNest.Forms
updateDrawingListTimer.Elapsed += drawingListUpdateTimer_Elapsed;
Document = new Document { Nest = nest };
+ // Units are read when each check starts, matching the manual command.
+ PlateView.SetOverlapAutoCheck(() => Nest.Units);
PlateManager = new PlateManager(nest);
PlateManager.CurrentPlateChanged += PlateManager_CurrentPlateChanged;
diff --git a/README.md b/README.md
index 38ff05b..9942a12 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +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, [plate- or nest-wide automatic scrap cutoffs with a minimum tail-to-keep setting](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, with area shading, pair centroids, and hover details through View > Overlap Check. [Usage and limitations](docs/geometry/visual-overlap-check.md).
+- **Visual overlap check** — highlight shared material on the active plate, rechecked automatically after edits, including containment and cutouts, with area shading, pair centroids, and hover details 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). [Pre-post verification](docs/post-verification.md) checks overlaps, missing lead-ins, and rapid crossings, with explicit risk acknowledgment required to bypass warnings.
## Requirements
diff --git a/docs/geometry/visual-overlap-check.md b/docs/geometry/visual-overlap-check.md
index a0b0970..445f455 100644
--- a/docs/geometry/visual-overlap-check.md
+++ b/docs/geometry/visual-overlap-check.md
@@ -33,10 +33,21 @@ 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.
+In a nest window the active plate is checked automatically. Any layout edit
+(add, remove, reorder, move, rotate, fill) clears the overlay and shows
+**Overlaps: check pending…**; once the layout has been unchanged for 0.5 s and no
+mouse button, modal dialog, or fill progress window is active, the check reruns.
+Automatic results appear only in the canvas label, so the status bar keeps the
+last command's message, and an automatic check keeps Display > Off rather than
+switching to Areas. Rechecks are incremental: drawing material is prepared once
+and reused, and pairs whose two parts have not moved reuse the previous result,
+so only the moved parts' neighbors are clipped again. Canceling a check is
+respected until the layout changes again, and a failed layout is not retried
+until it changes. Check Active Plate remains available and always runs at once.
+
+Plate changes reset the check. Pan, zoom, selection, and display changes do not
+rerun geometry. Drawing-editor loading invalidates before loading (dropping cached
+material), even if the dialog is later canceled. There is no check during export.
## Analysis API
@@ -185,7 +196,29 @@ 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
+generations, cancellation, and the GDI display cache. `EditNestForm` enables its
+automatic recheck with `PlateView.SetOverlapAutoCheck(() => Nest.Units)`; other
+PlateView hosts (fill previews, pattern tiles) stay manual. The debounce decision
+is the Linux-testable `OverlapAutoCheckScheduler`: collection events and every
+paint's stamp comparison restart a WinForms timer, and the tick starts a check only
+if the stamp is unchanged since the last observation and no interaction is active.
+
+The controller passes one `OverlapMaterialCache` to
+`PlateOverlapAnalyzer.Capture(parts, cache)` and its last published report to
+`Analyze(snapshot, previous)`. The cache keeps each clean `Program`'s converted
+entities and prepared, validated material (weakly keyed by program reference; a
+changed code count or rotation is detected). Preparing material dominates
+first-check time for drawings with many holes. Incremental analysis reuses a pair
+only when both parts have the same cached source, bit-identical pose, and the same
+relative input order (clipping is operand-order sensitive), then renumbers it.
+`InvalidateOverlapCheck()` clears the cache and baseline, so in-place program
+editors must keep calling it before loading.
+
+Measured on 501 real PEP-converted plates with 2 to 384 parts, a from-scratch check
+takes median 1 ms, p99 368 ms and max 6.5 s (a 299-part plate); an incremental
+recheck after moving one part takes median 0.1 ms, p99 20 ms and max 35 ms. A
+comparison against uncached full analysis over 2505 edits (nudge, drag onto
+another part, move three, delete first, swap order) matched exactly. 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.
@@ -201,9 +234,10 @@ radii. Hover clears on edits, mode/request/view changes, leave, and teardown.
Diagnostic details draw above action adorners and take precedence over the normal
part-name tooltip only while visible.
-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.
+Next hardening: the first check of a plate with a hole-heavy drawing can still
+take seconds, and cancellation cannot interrupt the interior of material
+preparation or one kernel operation. Prepared per-part triangulations are not yet
+cached across requests.
## Verification
@@ -215,9 +249,14 @@ inputs, snapshot isolation, read-only output, cutting-program independence, and
cancellation. Run:
```sh
-dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~PlateOverlapAnalyzerTests|FullyQualifiedName~OverlapReportStateTests|FullyQualifiedName~PolygonAreaMomentsTests|FullyQualifiedName~OverlapPairPresentationTests|FullyQualifiedName~OverlapHoverPagesTests'
+dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~PlateOverlapAnalyzerTests|FullyQualifiedName~OverlapReportStateTests|FullyQualifiedName~PolygonAreaMomentsTests|FullyQualifiedName~OverlapPairPresentationTests|FullyQualifiedName~OverlapHoverPagesTests|FullyQualifiedName~IncrementalOverlapAnalysisTests|FullyQualifiedName~OverlapAutoCheckSchedulerTests'
```
+`IncrementalOverlapAnalysisTests` compares incremental rechecks with uncached full
+analysis across moves, rotation, deletion, insertion, order swaps, renames and
+coincident duplicates, and covers pair reuse, issue renumbering, cache clearing and
+cancellation. `OverlapAutoCheckSchedulerTests` covers the quiet period, interaction
+waits, and the no-retry rule for canceled or failed layouts.
`OverlapReportStateTests` verifies request supersession, exact pose/reference
freshness, stale clearing, cancellation, and incomplete-versus-clear messaging.
`PolygonAreaMomentsTests` covers analytic
@@ -227,7 +266,8 @@ formatting, sequence labels, coincident ordering, and zoom-independent DPI hit
radii. `OverlapHoverPagesTests` proves bounded continuation pages retain every
pair and long/Unicode name, with navigation bounds and explicit tiny-view failure.
`OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs` adds STA worker/publication,
-menu/MDI, path-cache, uniform-fill pixel, and control-lifetime checks. Run those
+menu/MDI, path-cache, uniform-fill pixel, control-lifetime, and automatic-recheck
+(debounce, drag detection, busy wait, supersession, display/status) checks. Run those
on Windows:
```sh
@@ -238,5 +278,6 @@ 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,
+the pending label and automatic recheck after dragging a part onto another,
crowded-marker PageUp/PageDown access to the last pair, and repeated
check/toggle/close cycles without GDI/disposed-control errors.