feat(overlap): auto-recheck the active plate after layout edits settle

Nest windows now rerun the overlap check once the layout has been
unchanged for 0.5 s, instead of leaving 'Overlaps: not checked'. Edits
show 'Overlaps: check pending...'; drags are caught by the paint-time
pose stamp, collection edits by their events. The check waits while a
mouse button, modal dialog or fill is active, supersedes a running check
when the layout moves again, and does not retry a canceled or failed
layout until it changes. Rechecks use the incremental analyzer, so only
the moved parts' neighbors are recomputed.

Automatic results update only the canvas label (the status bar keeps the
last command's message) and keep Display > Off. Check Active Plate still
runs immediately. InvalidateOverlapCheck now also drops cached material.
This commit is contained in:
aj
2026-09-29 09:31:51 -04:00
parent 99c31748ba
commit 29953ea601
10 changed files with 597 additions and 30 deletions
@@ -0,0 +1,80 @@
namespace OpenNest.Diagnostics;
public enum OverlapAutoCheckStep { None, Wait, Check }
/// <summary>
/// UI-thread debounce policy for automatic overlap rechecks; the host owns the timer.
/// Restart the quiet-period timer whenever <see cref="Observe"/> returns true, and call
/// <see cref="Elapsed"/> 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.
/// </summary>
public sealed class OverlapAutoCheckScheduler
{
private OverlapGeometryStamp pending;
private OverlapGeometryStamp lastRequest;
public bool IsWaiting => pending != null;
/// <summary>
/// Call after the report state's freshness check. True means (re)start the quiet-period
/// timer; false means leave it as it is.
/// </summary>
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;
}
/// <summary>
/// Call when the quiet period ends. <see cref="OverlapAutoCheckStep.Wait"/> means restart the
/// timer (the layout moved or an interaction is still running); only
/// <see cref="OverlapAutoCheckStep.Check"/> starts a request.
/// </summary>
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;
}
/// <summary>Record every request start, manual or automatic.</summary>
public void Started(Plate plate)
{
pending = null;
lastRequest = OverlapGeometryStamp.Capture(plate);
}
/// <summary>Forget pending and previous requests (plate switch, handle loss, disable).</summary>
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
};
}
@@ -30,11 +30,15 @@ public sealed class OverlapReportState
_ => "Overlaps: not checked"
};
public long Begin(Plate plate)
/// <summary>
/// Starts a request. A manual check from Off shows Areas; an automatic recheck keeps
/// the user's display choice, including Off.
/// </summary>
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;
}
@@ -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));
}
}
@@ -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()
{
@@ -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<bool>? 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();
+159 -16
View File
@@ -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;
/// <summary>
/// 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 cref="AutoCheckDelay"/> (see <see cref="OverlapAutoCheckScheduler"/>).
/// </summary>
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<Units> autoCheckUnits;
private ObservableList<Part> 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<PlateOverlapSnapshot, CancellationToken, PlateOverlapReport> 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<bool> IsInteractionActive { get; set; } = DefaultInteractionActive;
public int AutoCheckDelay
{
get => autoCheckTimer.Interval;
set => autoCheckTimer.Interval = value;
}
public string Message => autoCheck.IsWaiting
? "Overlaps: check pending…"
: state.Message;
/// <summary>
/// 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.
/// </summary>
public void SetAutoCheck(Func<Units> 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.
}
/// <summary>
/// For editors that may mutate clean drawing programs in place: drops cached material
/// as well as the current report. Call before loading the editor.
/// </summary>
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);
}
/// <summary>
/// Restart the quiet period when the layout changed since it was last observed.
/// <paramref name="repaint"/> redraws the label if its pending text changed; paint and
/// NotifyChanged callers draw or invalidate themselves.
/// </summary>
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();
/// <summary>The timer's tick. Returns the started request, if any, for STA tests.</summary>
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<System.Windows.Forms.Form>()
.Any(form => form.Modal || form is NestProgressForm);
private void PartAdded(object sender, ItemAddedEventArgs<Part> e) => Invalidate();
private void PartRemoved(object sender, ItemRemovedEventArgs<Part> e) => Invalidate();
private void PartChanged(object sender, ItemChangedEventArgs<Part> 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();
+17 -1
View File
@@ -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();
/// <summary>
/// Call before any editor that may mutate clean drawing programs in place: marks the
/// report out of date and drops cached drawing material.
/// </summary>
public void InvalidateOverlapCheck() => overlapOverlay.InvalidateGeometry();
/// <summary>
/// Recheck overlaps automatically once layout edits have been quiet for
/// <see cref="OverlapAutoCheckDelay"/> ms. Null restores manual-only checks.
/// </summary>
public void SetOverlapAutoCheck(Func<Units> units) => overlapOverlay.SetAutoCheck(units);
public int OverlapAutoCheckDelay
{
get => overlapOverlay.AutoCheckDelay;
set => overlapOverlay.AutoCheckDelay = value;
}
public string Status
{
+2
View File
@@ -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;
+1 -1
View File
@@ -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
+51 -10
View File
@@ -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.