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.