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:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user