From e886bcfa358a11f12d413c583dc3c641a521f4e2 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 1 Oct 2026 15:10:17 -0400 Subject: [PATCH] fix(overlap): retain unchanged pair highlights during rechecks --- .../Diagnostics/OverlapGeometryStamp.cs | 14 ++ .../Diagnostics/OverlapReportState.cs | 50 ++++++- .../Diagnostics/OverlapReportStateTests.cs | 139 ++++++++++++++++++ .../PlateOverlapOverlayTests.cs | 95 +++++++++++- OpenNest/Controls/OverlapOverlayController.cs | 38 +++-- docs/geometry/visual-overlap-check.md | 31 +++- 6 files changed, 342 insertions(+), 25 deletions(-) diff --git a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs index 6f357cc..fb7f33f 100644 --- a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs +++ b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs @@ -32,6 +32,20 @@ public sealed class OverlapGeometryStamp return true; } + /// + /// Conservative display reuse: only unchanged ordered slots on the same plate survive. + /// Index-changing edits may hide extra pairs, but cannot attach old geometry to new parts. + /// + internal bool[] UnchangedSlots(Plate current) + { + var matches = new bool[entries.Length]; + if (!ReferenceEquals(plate, current)) + return matches; + for (var i = 0; i < entries.Length && i < current.Parts.Count; i++) + matches[i] = entries[i].Matches(current.Parts[i]); + return matches; + } + private readonly struct Entry { private readonly Part part; diff --git a/OpenNest.Core/Diagnostics/OverlapReportState.cs b/OpenNest.Core/Diagnostics/OverlapReportState.cs index ff66dcd..3da9309 100644 --- a/OpenNest.Core/Diagnostics/OverlapReportState.cs +++ b/OpenNest.Core/Diagnostics/OverlapReportState.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Linq; namespace OpenNest.Diagnostics; @@ -9,12 +11,16 @@ public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Fail public sealed class OverlapReportState { private OverlapGeometryStamp stamp; + private OverlapGeometryStamp displayStamp; + private OverlapGeometryStamp observedDisplayStamp; private int uncheckedPartCount; public long Generation { get; private set; } public OverlapCheckStatus Status { get; private set; } = OverlapCheckStatus.NotChecked; public OverlapDisplayMode DisplayMode { get; set; } = OverlapDisplayMode.Areas; public PlateOverlapReport Report { get; private set; } + /// Known overlap pairs safe to draw, even while the full layout needs a recheck. + public IReadOnlyList DisplayPairs { get; private set; } = Array.Empty(); public bool IsRunning => Status == OverlapCheckStatus.Checking; public string Message => Status switch @@ -36,7 +42,8 @@ public sealed class OverlapReportState /// public long Begin(Plate plate, bool automatic = false) { - Clear(OverlapCheckStatus.Checking); + RefreshDisplayPairs(plate); + Clear(OverlapCheckStatus.Checking, preserveDisplay: true); stamp = OverlapGeometryStamp.Capture(plate); if (!automatic && DisplayMode == OverlapDisplayMode.Off) DisplayMode = OverlapDisplayMode.Areas; @@ -48,6 +55,9 @@ public sealed class OverlapReportState if (!CanComplete(generation, plate)) return false; Report = report; + DisplayPairs = report.Pairs; + displayStamp = stamp; + observedDisplayStamp = stamp; uncheckedPartCount = CountUncheckedParts(report.Issues); Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete; return true; @@ -66,20 +76,52 @@ public sealed class OverlapReportState public bool EnsureFresh(Plate plate) { + RefreshDisplayPairs(plate); if (stamp == null) return false; if (stamp.Matches(plate)) return true; - Invalidate(); + Invalidate(plate); return false; } + /// Layout edit: retain only pairs whose two ordered slots still match exactly. + public void Invalidate(Plate plate) + { + RefreshDisplayPairs(plate); + if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete) + Clear(OverlapCheckStatus.Stale, preserveDisplay: true); + } + + /// In-place geometry edits and teardown must forget every cached display pair. public void Invalidate() { + ClearDisplayPairs(); if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete) Clear(OverlapCheckStatus.Stale); } + private void RefreshDisplayPairs(Plate plate) + { + if (displayStamp == null || observedDisplayStamp?.Matches(plate) == true) + return; + var unchanged = displayStamp.UnchangedSlots(plate); + var retained = DisplayPairs.Where(pair => unchanged[pair.PartAId] && unchanged[pair.PartBId]).ToList(); + if (retained.Count != DisplayPairs.Count) + DisplayPairs = retained.AsReadOnly(); + if (DisplayPairs.Count == 0) + ClearDisplayPairs(); + else + observedDisplayStamp = OverlapGeometryStamp.Capture(plate); + } + + private void ClearDisplayPairs() + { + DisplayPairs = Array.Empty(); + displayStamp = null; + observedDisplayStamp = null; + } + public void Cancel() { if (IsRunning) @@ -88,8 +130,10 @@ public sealed class OverlapReportState public void Reset() => Clear(OverlapCheckStatus.NotChecked); - private void Clear(OverlapCheckStatus status) + private void Clear(OverlapCheckStatus status, bool preserveDisplay = false) { + if (!preserveDisplay) + ClearDisplayPairs(); Generation++; Report = null; uncheckedPartCount = 0; diff --git a/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs b/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs index a282516..6cc1259 100644 --- a/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs +++ b/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs @@ -215,6 +215,145 @@ public class OverlapReportStateTests Assert.Equal("No material overlaps detected", state.Message); } + [Fact] + public void MovingOnePartRetainsOnlyUnaffectedPairsThroughRecheck() + { + var plate = PlateWithTwoPairs(); + var state = new OverlapReportState(); + var report = Analyze(plate); + Assert.True(state.TryPublish(state.Begin(plate), plate, report)); + var untouched = report.Pairs[0]; + plate.Parts[3].Offset(1e-10, 0); + + Assert.False(state.EnsureFresh(plate)); + Assert.Null(state.Report); + Assert.Equal(OverlapCheckStatus.Stale, state.Status); + Assert.Same(untouched, Assert.Single(state.DisplayPairs)); + var display = state.DisplayPairs; + Assert.False(state.EnsureFresh(plate)); + Assert.Same(display, state.DisplayPairs); + + var request = state.Begin(plate, automatic: true); + Assert.Equal(OverlapCheckStatus.Checking, state.Status); + Assert.Null(state.Report); + Assert.Same(display, state.DisplayPairs); + Assert.True(state.TryPublish(request, plate, Analyze(plate))); + Assert.Equal(2, state.DisplayPairs.Count); + Assert.Same(Assert.IsType(state.Report).Pairs, state.DisplayPairs); + } + + [Theory] + [InlineData("move")] + [InlineData("rotate")] + [InlineData("placed-program")] + [InlineData("clean-program")] + [InlineData("cutoff")] + [InlineData("replace")] + [InlineData("remove")] + [InlineData("reorder")] + public void FurtherEditsDropAffectedPairsEvenBeforeTheNextCompletedCheck(string edit) + { + var plate = PlateWithTwoPairs(); + var state = new OverlapReportState(); + Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate))); + plate.Parts[3].Offset(0.5, 0); + Assert.False(state.EnsureFresh(plate)); + Assert.Single(state.DisplayPairs); + var request = state.Begin(plate, automatic: true); + var pending = Analyze(plate); + var part = plate.Parts[0]; + switch (edit) + { + case "move": part.Offset(0, 1e-10); break; + case "rotate": part.Rotate(0.01); break; + case "placed-program": part.Update(); break; + case "clean-program": part.BaseDrawing.Program = (Program)part.BaseDrawing.Program.Clone(); break; + case "cutoff": part.BaseDrawing.IsCutOff = true; break; + case "replace": plate.Parts[0] = Rectangle(); break; + case "remove": plate.Parts.RemoveAt(0); break; + case "reorder": (plate.Parts[0], plate.Parts[1]) = (plate.Parts[1], plate.Parts[0]); break; + } + Assert.False(state.EnsureFresh(plate)); + Assert.Empty(state.DisplayPairs); + Assert.False(state.TryPublish(request, plate, pending)); + Assert.Null(state.Report); + Assert.Equal(OverlapCheckStatus.Stale, state.Status); + Assert.DoesNotContain("No material overlaps", state.Message); + } + + [Theory] + [InlineData("invalidate")] + [InlineData("reset")] + [InlineData("plate")] + [InlineData("cancel")] + [InlineData("fail")] + public void HardInvalidationAndRequestFailureClearRetainedPairs(string edit) + { + var plate = PlateWithTwoPairs(); + var state = new OverlapReportState(); + Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate))); + plate.Parts[3].Offset(1, 0); + Assert.False(state.EnsureFresh(plate)); + Assert.Single(state.DisplayPairs); + var request = state.Begin(plate); + switch (edit) + { + case "invalidate": state.Invalidate(); break; + case "reset": state.Reset(); break; + case "plate": Assert.False(state.EnsureFresh(new Plate())); break; + case "cancel": state.Cancel(); break; + case "fail": Assert.True(state.TryFail(request, plate)); break; + } + Assert.Empty(state.DisplayPairs); + Assert.Null(state.Report); + } + + [Fact] + public void AdditionalUnrelatedMotionDoesNotReplaceTheRetainedDisplayList() + { + var plate = PlateWithTwoPairs(); + var state = new OverlapReportState(); + Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate))); + plate.Parts[3].Offset(1, 0); + Assert.False(state.EnsureFresh(plate)); + var display = state.DisplayPairs; + Assert.Single(display); + plate.Parts[3].Offset(1, 0); + Assert.False(state.EnsureFresh(plate)); + Assert.Same(display, state.DisplayPairs); + state.Begin(plate); + Assert.Same(display, state.DisplayPairs); + } + + [Fact] + public void AppendingPartInvalidatesTheFullReportWithoutReplacingUnchangedDisplayPairs() + { + var plate = PlateWithParts(); + var state = new OverlapReportState(); + Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate))); + var display = state.DisplayPairs; + plate.Parts.Add(Rectangle()); + state.Invalidate(plate); // Same entry point as the controller's collection event. + Assert.Null(state.Report); + Assert.Equal(OverlapCheckStatus.Stale, state.Status); + Assert.Same(display, state.DisplayPairs); + Assert.Single(state.DisplayPairs); + Assert.False(state.EnsureFresh(plate)); + Assert.Same(display, state.DisplayPairs); + } + + private static Plate PlateWithTwoPairs() + { + var plate = PlateWithParts(); + var a = Rectangle(); + var b = Rectangle(); + a.Offset(20, 0); + b.Offset(20, 0); + plate.Parts.Add(a); + plate.Parts.Add(b); + return plate; + } + private static PlateOverlapReport Analyze(Plate plate) => PlateOverlapAnalyzer.Analyze(plate.Parts.ToArray()); private static Plate PlateWithParts() diff --git a/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs index 2ac0b84..df992f8 100644 --- a/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs +++ b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs @@ -97,6 +97,80 @@ public class PlateOverlapOverlayTests Assert.Null(run.View.OverlapOverlay.CachedPath); }); + [Fact] + public void UnchangedPairStaysVisibleDuringMotionAndPendingRecheck() => RunSta(() => + { + using var run = new OverlayRun(); + run.View.Plate.Parts.Add(Rectangle(20)); + run.View.Plate.Parts.Add(Rectangle(21)); + run.Finish(); + run.View.OverlapDisplay = OverlapDisplayMode.Both; + run.View.ZoomToPoint(new Vector(), 20); + using var image = new Bitmap(240, 240); + using var graphics = Graphics.FromImage(image); + var overlay = run.View.OverlapOverlay; + var untouchedPoint = run.View.PointWorldToGraph(new Vector(2, 2)); + var movedPoint = run.View.PointWorldToGraph(new Vector(22, 2)); + overlay.Draw(graphics); + Assert.True(overlay.CachedPath.IsVisible(untouchedPoint)); + Assert.True(overlay.CachedPath.IsVisible(movedPoint)); + + run.View.Plate.Parts[3].Offset(1e-10, 0); + overlay.Draw(graphics); + Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus); + Assert.Null(run.View.OverlapReport); + Assert.True(overlay.CachedPath.IsVisible(untouchedPoint)); + Assert.False(overlay.CachedPath.IsVisible(movedPoint)); + var retainedPath = overlay.CachedPath; + run.View.Plate.Parts[3].Offset(1, 0); + overlay.Draw(graphics); + Assert.Same(retainedPath, overlay.CachedPath); + Assert.Equal(1, run.Calls); // Paint never analyzes. + + var task = run.Start(); + var pending = run.Next(); + overlay.Draw(graphics); // Both areas and centroid markers must render without a full report. + Assert.Equal(OverlapCheckStatus.Checking, run.View.OverlapStatus); + Assert.Same(retainedPath, overlay.CachedPath); + run.View.Plate.Parts[0].Offset(1e-10, 0); + overlay.Draw(graphics); + Assert.Null(overlay.CachedPath); + pending.Complete(); + run.Pump(task); + Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus); + Assert.Null(run.View.OverlapReport); + overlay.Draw(graphics); + Assert.Null(overlay.CachedPath); // Late completion cannot restore the removed pair. + }); + + [Theory] + [InlineData("editor")] + [InlineData("cancel")] + [InlineData("failure")] + public void RetainedPathClearsOnHardInvalidationOrRequestFailure(string edit) => RunSta(() => + { + using var run = new OverlayRun(); + run.View.Plate.Parts.Add(Rectangle(20)); + run.Finish(); + run.View.Plate.Parts[2].Offset(1, 0); + run.Paint(); + var path = run.View.OverlapOverlay.CachedPath; + Assert.NotNull(path); + var task = run.Start(); + var pending = run.Next(); + Assert.Same(path, run.View.OverlapOverlay.CachedPath); + switch (edit) + { + case "editor": run.View.InvalidateOverlapCheck(); pending.Complete(); break; + case "cancel": run.View.CancelOverlapCheck(); pending.Complete(); break; + case "failure": pending.Fail(); break; + } + run.Pump(task); + Assert.Null(run.View.OverlapReport); + run.Paint(); + Assert.Null(run.View.OverlapOverlay.CachedPath); + }); + [Fact] public void DisplayPanZoomAndPreviewDoNotAnalyzeOrChangeCommittedParts() => RunSta(() => { @@ -233,11 +307,10 @@ public class PlateOverlapOverlayTests }); [Theory] - [InlineData("add")] [InlineData("remove")] [InlineData("replace")] [InlineData("clear")] - public void CollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() => + public void SlotChangingCollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() => { using var run = new OverlayRun(); run.Finish(); @@ -247,7 +320,6 @@ public class PlateOverlapOverlayTests Assert.NotNull(run.View.OverlapOverlay.CachedPath); switch (edit) { - case "add": run.View.Plate.Parts.Add(Rectangle()); break; case "remove": run.View.Plate.Parts.RemoveAt(0); break; case "replace": run.View.Plate.Parts[0] = Rectangle(); break; case "clear": run.View.Plate.Parts.Clear(); break; @@ -257,6 +329,23 @@ public class PlateOverlapOverlayTests Assert.Null(run.View.OverlapOverlay.CachedPath); }); + [Fact] + public void AppendingPartRetainsTheExistingPairsPathButInvalidatesTheFullReport() => RunSta(() => + { + using var run = new OverlayRun(); + run.Finish(); + run.Paint(); + var path = run.View.OverlapOverlay.CachedPath; + Assert.NotNull(path); + run.View.Plate.Parts.Add(Rectangle()); + Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus); + Assert.Null(run.View.OverlapReport); + Assert.Same(path, run.View.OverlapOverlay.CachedPath); + run.Paint(); + Assert.Same(path, run.View.OverlapOverlay.CachedPath); + Assert.Equal(1, run.Calls); + }); + [Fact] public void MenusFollowMdiActivationDocumentDisplayAndRunningState() => RunSta(() => { diff --git a/OpenNest/Controls/OverlapOverlayController.cs b/OpenNest/Controls/OverlapOverlayController.cs index feb1076..6e1ce3d 100644 --- a/OpenNest/Controls/OverlapOverlayController.cs +++ b/OpenNest/Controls/OverlapOverlayController.cs @@ -33,7 +33,7 @@ internal sealed class OverlapOverlayController : IDisposable private ObservableList observedParts; private CancellationTokenSource cancellation; private GraphicsPath path; - private PlateOverlapReport pathReport; + private IReadOnlyList pathPairs; private float pathScale; private Units capturedUnits; private IReadOnlyList hoveredPairs = Array.Empty(); @@ -139,7 +139,6 @@ internal sealed class OverlapOverlayController : IDisposable var source = new CancellationTokenSource(); cancellation = source; var token = source.Token; - ReleasePath(); NotifyChanged(toStatusBar: !automatic); try { @@ -204,13 +203,22 @@ internal sealed class OverlapOverlayController : IDisposable { materialCache.Clear(); baseline = null; - Invalidate(); + state.Invalidate(); + CancelWorker(); + ReleasePath(); + NotifyChanged(); } public void Invalidate() { var generation = state.Generation; - state.Invalidate(); + var display = state.DisplayPairs; + state.Invalidate(view.Plate); + if (!ReferenceEquals(display, state.DisplayPairs)) + { + ReleasePath(); + ClearHover(); + } if (generation == state.Generation) { // Already unchecked/stale: an edit restarts the quiet period. Bulk fills raise one @@ -223,7 +231,6 @@ internal sealed class OverlapOverlayController : IDisposable return; } CancelWorker(); - ReleasePath(); NotifyChanged(); } @@ -250,6 +257,8 @@ internal sealed class OverlapOverlayController : IDisposable private void NotifyChanged(bool toStatusBar = false) { + if (!ReferenceEquals(pathPairs, state.DisplayPairs)) + ReleasePath(); ClearHover(); if (disposed || view.IsDisposed || view.Disposing) return; @@ -343,7 +352,7 @@ internal sealed class OverlapOverlayController : IDisposable UpdateAutoCheck(repaint: false); // Drags/nudges are only visible to the stamp; the label is drawn below. ValidateHoverTransform(); - if (state.Report != null) + if (state.DisplayPairs.Count > 0) { if (state.DisplayMode is OverlapDisplayMode.Areas or OverlapDisplayMode.Both) { @@ -366,11 +375,16 @@ internal sealed class OverlapOverlayController : IDisposable if (disposed) return false; var generation = state.Generation; + var display = state.DisplayPairs; var fresh = state.EnsureFresh(view.Plate); + if (!ReferenceEquals(display, state.DisplayPairs)) + { + ReleasePath(); + ClearHover(); + } if (generation != state.Generation) { CancelWorker(); - ReleasePath(); NotifyChanged(); } return fresh; @@ -457,7 +471,7 @@ internal sealed class OverlapOverlayController : IDisposable using var crosshair = new Pen(Color.DarkRed, 2 * dpiScale); // Stack coincident pair labels instead of replacing them with a fragment count. var labelRows = new Dictionary(); - foreach (var pair in state.Report.Pairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId)) + foreach (var pair in state.DisplayPairs.OrderBy(pair => pair.PartAId).ThenBy(pair => pair.PartBId)) { var center = view.PointWorldToGraph(pair.Centroid); if (!float.IsFinite(center.X) || !float.IsFinite(center.Y)) @@ -532,13 +546,13 @@ internal sealed class OverlapOverlayController : IDisposable private void EnsurePath() { - if (ReferenceEquals(pathReport, state.Report) && pathScale == view.ViewScale && path != null) + if (ReferenceEquals(pathPairs, state.DisplayPairs) && pathScale == view.ViewScale && path != null) return; ReleasePath(); var next = new GraphicsPath(FillMode.Winding); try { - foreach (var pair in state.Report.Pairs) + foreach (var pair in state.DisplayPairs) { foreach (var region in pair.Regions) { @@ -560,7 +574,7 @@ internal sealed class OverlapOverlayController : IDisposable } } path = next; - pathReport = state.Report; + pathPairs = state.DisplayPairs; pathScale = view.ViewScale; } catch @@ -592,7 +606,7 @@ internal sealed class OverlapOverlayController : IDisposable { path?.Dispose(); path = null; - pathReport = null; + pathPairs = null; } public void Dispose() diff --git a/docs/geometry/visual-overlap-check.md b/docs/geometry/visual-overlap-check.md index 445f455..c5a89b3 100644 --- a/docs/geometry/visual-overlap-check.md +++ b/docs/geometry/visual-overlap-check.md @@ -34,8 +34,10 @@ fragment counts. This diagnostic checks shared material, not minimum spacing, plate edges, or cutting-path crossings. 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 +(add, remove, reorder, move, rotate, fill) invalidates the full report and shows +**Overlaps: check pending…**, but highlights for unchanged pairs remain visible. +Only highlights involving changed ordered input slots are removed; 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 @@ -211,8 +213,21 @@ 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. +`InvalidateOverlapCheck()` clears the cache, baseline and every highlight, so +in-place program editors must keep calling it before loading. + +Moving one part hides only highlights involving changed parts. Unchanged pairs +remain visible throughout the quiet period and background recheck, sharing their +existing immutable regions rather than recalculating them. `OverlapReportState` +keeps these `DisplayPairs` separate from its full `Report`: the full report is +unavailable and the label stays pending/out-of-date/checking until a fresh result +is published. Retained highlights are known overlaps, never an all-clear for the +edited layout. Each paint checks exact poses and references again, including +further edits while an earlier check is pending. Hover details remain disabled +until the full report is current. Collection edits conservatively discard pairs +whose ordered input slots changed; this display-only path does not renumber them. +Explicit geometry invalidation, plate switch, handle loss, cancellation and +failure clear all retained highlights. 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 @@ -227,7 +242,7 @@ PlateView draws the controller overlay after work-area/debug-remnant drawing and before action paint subscribers and hover tooltips. One consistently wound path is filled once, avoiding fragment outlines, internal triangulation seams, and darker triple coverage. World-to-graph conversion excludes pan, because PlateView -already applies origin translation. Paths are rebuilt for report/scale changes, +already applies origin translation. Paths are rebuilt for displayed-pair/scale changes, not ordinary repaints or panning. The state label saves/restores graphics state. Centroid hit tests use only cached report coordinates and DPI-scaled screen radii. Hover clears on edits, mode/request/view changes, leave, and teardown. @@ -258,7 +273,8 @@ coincident duplicates, and covers pair reuse, issue renumbering, cache clearing 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. +freshness, per-pair display retention through repeated edits and pending checks, +hard invalidation, cancellation, and incomplete-versus-clear messaging. `PolygonAreaMomentsTests` covers analytic centers, unequal/disconnected fragments, winding, closure, large translations, and invalid/overflow cases. `OverlapPairPresentationTests` checks adaptive unit @@ -277,7 +293,8 @@ dotnet test OpenNest.WinForms.Tests/OpenNest.WinForms.Tests.csproj Linux can cross-build with `-p:EnableWindowsTargeting=true`, but that does not execute Windows tests or verify appearance, DPI, or interaction. On Windows, check partial overlap, containment, inside-hole placement, pan/zoom and quadrant -alignment, stale clearing during edits/plate switches, converter cancellation, +alignment, changed-pair clearing and unchanged-pair retention during edits and +pending rechecks, full clearing on plate switches and 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.