diff --git a/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs new file mode 100644 index 0000000..6f357cc --- /dev/null +++ b/OpenNest.Core/Diagnostics/OverlapGeometryStamp.cs @@ -0,0 +1,66 @@ +using System; +using OpenNest.CNC; + +namespace OpenNest.Diagnostics; + +/// +/// Cheap ordered identity/pose check, not a geometry hash. In-place geometry editors must +/// explicitly invalidate before mutating. Capture and match only on the model's UI thread. +/// +public sealed class OverlapGeometryStamp +{ + private readonly Plate plate; + private readonly Entry[] entries; + + private OverlapGeometryStamp(Plate plate) + { + this.plate = plate; + entries = new Entry[plate.Parts.Count]; + for (var i = 0; i < entries.Length; i++) + entries[i] = new Entry(plate.Parts[i]); + } + + public static OverlapGeometryStamp Capture(Plate plate) => new(plate); + + public bool Matches(Plate current) + { + if (!ReferenceEquals(plate, current) || current.Parts.Count != entries.Length) + return false; + for (var i = 0; i < entries.Length; i++) + if (!entries[i].Matches(current.Parts[i])) + return false; + return true; + } + + private readonly struct Entry + { + private readonly Part part; + private readonly Drawing drawing; + private readonly Program placedProgram; + private readonly Program cleanProgram; + private readonly long x, y, rotation; + private readonly bool isCutOff; + + public Entry(Part part) + { + this.part = part; + drawing = part.BaseDrawing; + placedProgram = part.Program; + cleanProgram = drawing.Program; + x = BitConverter.DoubleToInt64Bits(part.Location.X); + y = BitConverter.DoubleToInt64Bits(part.Location.Y); + rotation = BitConverter.DoubleToInt64Bits(part.Rotation); + isCutOff = drawing.IsCutOff; + } + + public bool Matches(Part current) => + ReferenceEquals(part, current) + && ReferenceEquals(drawing, current.BaseDrawing) + && ReferenceEquals(placedProgram, current.Program) + && ReferenceEquals(cleanProgram, current.BaseDrawing.Program) + && x == BitConverter.DoubleToInt64Bits(current.Location.X) + && y == BitConverter.DoubleToInt64Bits(current.Location.Y) + && rotation == BitConverter.DoubleToInt64Bits(current.Rotation) + && isCutOff == current.BaseDrawing.IsCutOff; + } +} diff --git a/OpenNest.Core/Diagnostics/OverlapReportState.cs b/OpenNest.Core/Diagnostics/OverlapReportState.cs new file mode 100644 index 0000000..af10966 --- /dev/null +++ b/OpenNest.Core/Diagnostics/OverlapReportState.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; + +namespace OpenNest.Diagnostics; + +public enum OverlapDisplayMode { Off, Areas } +public enum OverlapCheckStatus { NotChecked, Checking, Current, Incomplete, Failed, Canceled, Stale } + +/// UI-thread lifecycle policy, independent of workers, GDI and view transforms. +public sealed class OverlapReportState +{ + private OverlapGeometryStamp stamp; + 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; } + public bool IsRunning => Status == OverlapCheckStatus.Checking; + + public string Message => Status switch + { + OverlapCheckStatus.Checking => "Checking overlaps…", + OverlapCheckStatus.Current => Report.Pairs.Count == 0 + ? "No material overlaps detected" : $"Overlaps: {Report.Pairs.Count} pairs", + OverlapCheckStatus.Incomplete => $"Overlap check incomplete: {Report.Pairs.Count} overlapping pairs; " + + $"{uncheckedPartCount} parts could not be checked", + OverlapCheckStatus.Failed => "Overlap check failed — run Check Overlaps again", + OverlapCheckStatus.Canceled => "Overlap check canceled", + OverlapCheckStatus.Stale => "Overlap check out of date — run Check Overlaps again", + _ => "Overlaps: not checked" + }; + + public long Begin(Plate plate) + { + Clear(OverlapCheckStatus.Checking); + stamp = OverlapGeometryStamp.Capture(plate); + DisplayMode = OverlapDisplayMode.Areas; + return Generation; + } + + public bool TryPublish(long generation, Plate plate, PlateOverlapReport report) + { + if (!CanComplete(generation, plate)) + return false; + Report = report; + uncheckedPartCount = CountUncheckedParts(report.Issues); + Status = report.IsComplete ? OverlapCheckStatus.Current : OverlapCheckStatus.Incomplete; + return true; + } + + public bool TryFail(long generation, Plate plate) + { + if (!CanComplete(generation, plate)) + return false; + Clear(OverlapCheckStatus.Failed); + return true; + } + + private bool CanComplete(long generation, Plate plate) => + generation == Generation && IsRunning && EnsureFresh(plate); + + public bool EnsureFresh(Plate plate) + { + if (stamp == null) + return false; + if (stamp.Matches(plate)) + return true; + Invalidate(); + return false; + } + + public void Invalidate() + { + if (Status is OverlapCheckStatus.Checking or OverlapCheckStatus.Current or OverlapCheckStatus.Incomplete) + Clear(OverlapCheckStatus.Stale); + } + + public void Cancel() + { + if (IsRunning) + Clear(OverlapCheckStatus.Canceled); + } + + public void Reset() => Clear(OverlapCheckStatus.NotChecked); + + private void Clear(OverlapCheckStatus status) + { + Generation++; + Report = null; + uncheckedPartCount = 0; + stamp = null; + Status = status; + } + + public static int CountUncheckedParts(IEnumerable issues) + { + var ids = new HashSet(); + foreach (var issue in issues) + { + ids.Add(issue.PartAId); + if (issue.PartBId.HasValue) + ids.Add(issue.PartBId.Value); + } + return ids.Count; + } +} diff --git a/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs b/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs new file mode 100644 index 0000000..1073f03 --- /dev/null +++ b/OpenNest.Tests/Diagnostics/OverlapReportStateTests.cs @@ -0,0 +1,212 @@ +using OpenNest.CNC; +using OpenNest.Diagnostics; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Diagnostics; + +public class OverlapReportStateTests +{ + [Fact] + public void RestartRejectsOldSuccessAndFailureWithoutDisturbingNewRequest() + { + var plate = PlateWithParts(); + var state = new OverlapReportState(); + var old = state.Begin(plate); + var current = state.Begin(plate); + Assert.False(state.TryPublish(old, plate, Analyze(plate))); + Assert.False(state.TryFail(old, plate)); + Assert.Equal(OverlapCheckStatus.Checking, state.Status); + Assert.True(state.TryPublish(current, plate, Analyze(plate))); + Assert.Equal(OverlapCheckStatus.Current, state.Status); + Assert.Equal("Overlaps: 1 pairs", state.Message); + } + + [Theory] + [InlineData("move-x")] + [InlineData("move-y")] + [InlineData("rotate")] + [InlineData("reorder")] + [InlineData("replace-part")] + [InlineData("replace-drawing")] + [InlineData("placed-program")] + [InlineData("clean-program")] + [InlineData("add")] + [InlineData("remove")] + [InlineData("plate")] + public void ExactOrderedStampRejectsEditsBeforePublishAndAfterCompletion(string edit) + { + foreach (var publishFirst in new[] { false, true }) + { + var plate = PlateWithParts(); + var state = new OverlapReportState(); + var request = state.Begin(plate); + var report = Analyze(plate); + if (publishFirst) + Assert.True(state.TryPublish(request, plate, report)); + var part = plate.Parts[0]; + switch (edit) + { + case "move-x": part.Offset(1e-10, 0); break; + case "move-y": part.Offset(0, 1e-10); break; + case "rotate": part.Rotate(0.01); break; + case "reorder": (plate.Parts[0], plate.Parts[1]) = (plate.Parts[1], plate.Parts[0]); break; + case "replace-part": plate.Parts[0] = part.CloneAtOffset(new Vector()); break; + case "replace-drawing": plate.Parts[0] = Rectangle(); break; + case "placed-program": part.Update(); break; + case "clean-program": part.BaseDrawing.Program = (Program)part.BaseDrawing.Program.Clone(); break; + case "add": plate.Parts.Add(Rectangle()); break; + case "remove": plate.Parts.RemoveAt(0); break; + case "plate": + var replacement = new Plate(); + replacement.Parts.AddRange(plate.Parts); + plate = replacement; + break; + } + Assert.False(state.EnsureFresh(plate)); + Assert.False(state.TryPublish(request, plate, report)); + Assert.Null(state.Report); + Assert.Equal(OverlapCheckStatus.Stale, state.Status); + Assert.Equal("Overlap check out of date — run Check Overlaps again", state.Message); + } + } + + [Fact] + public void CancelFailureAndIncompleteAreNeverClear() + { + var plate = new Plate(); + var state = new OverlapReportState(); + var request = state.Begin(plate); + state.Cancel(); + Assert.False(state.TryPublish(request, plate, Analyze(plate))); + Assert.Equal(OverlapCheckStatus.Canceled, state.Status); + Assert.Null(state.Report); + request = state.Begin(plate); + Assert.True(state.TryFail(request, plate)); + Assert.Equal(OverlapCheckStatus.Failed, state.Status); + Assert.Contains("failed", state.Message); + Assert.Null(state.Report); + + plate.Parts.Add(new Part(new Drawing("open", new Program()))); + request = state.Begin(plate); + Assert.True(state.TryPublish(request, plate, Analyze(plate))); + Assert.Equal(OverlapCheckStatus.Incomplete, state.Status); + Assert.Equal("Overlap check incomplete: 0 overlapping pairs; 1 parts could not be checked", state.Message); + state.Invalidate(); + Assert.Equal(OverlapCheckStatus.Stale, state.Status); + Assert.Null(state.Report); + } + + [Fact] + public void IssueCountIncludesBothPairIdsAndDeduplicatesAcrossAllIssues() + { + var issues = new[] + { + new PlateOverlapIssue(2, null, "input"), + new PlateOverlapIssue(2, 4, "pair"), + new PlateOverlapIssue(4, 7, "pair"), + new PlateOverlapIssue(7, null, "input") + }; + Assert.Equal(3, OverlapReportState.CountUncheckedParts(issues)); + } + + [Fact] + public void DisplayAndNonGeometryChangesRetainReportAndGeneration() + { + var plate = PlateWithParts(); + var state = new OverlapReportState(); + Assert.Equal(OverlapDisplayMode.Areas, state.DisplayMode); + var request = state.Begin(plate); + var report = Analyze(plate); + Assert.True(state.TryPublish(request, plate, report)); + state.DisplayMode = OverlapDisplayMode.Off; + plate.Parts[0].BaseDrawing.Name = "renamed"; + plate.Quantity = 2; + plate.PartSpacing = 123; + Assert.True(state.EnsureFresh(plate)); + Assert.Same(report, state.Report); + Assert.Equal(request, state.Generation); + state.DisplayMode = OverlapDisplayMode.Areas; + Assert.Same(report, state.Report); + Assert.Equal(request, state.Generation); + state.DisplayMode = OverlapDisplayMode.Off; + state.Begin(plate); + Assert.Equal(OverlapDisplayMode.Areas, state.DisplayMode); + Assert.Null(state.Report); + } + + [Fact] + public void NewPlateResetsStateButRetainsDocumentDisplayPreference() + { + var plate = PlateWithParts(); + var state = new OverlapReportState(); + var request = state.Begin(plate); + state.DisplayMode = OverlapDisplayMode.Off; + state.Reset(); + Assert.Equal(OverlapCheckStatus.NotChecked, state.Status); + Assert.Null(state.Report); + Assert.Equal(OverlapDisplayMode.Off, state.DisplayMode); + Assert.False(state.TryPublish(request, plate, Analyze(plate))); + } + + [Fact] + public void CutoffSlotsRemainPartOfTheOrderedIdentityStamp() + { + var plate = PlateWithParts(); + var cutoff = Rectangle(); + cutoff.BaseDrawing.IsCutOff = true; + plate.Parts.Insert(0, cutoff); + var state = new OverlapReportState(); + Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate))); + var pair = Assert.Single(state.Report.Pairs); + Assert.Equal((1, 2), (pair.PartAId, pair.PartBId)); + plate.Parts.RemoveAt(0); + Assert.False(state.EnsureFresh(plate)); + Assert.Null(state.Report); + } + + [Fact] + public void InPlaceGeometryEditorsMustExplicitlyInvalidate() + { + var plate = PlateWithParts(); + var state = new OverlapReportState(); + var request = state.Begin(plate); + var report = Analyze(plate); + // Deliberately not serialized or converted by the stamp. + plate.Parts[0].BaseDrawing.Program.Codes.Clear(); + Assert.True(state.EnsureFresh(plate)); + state.Invalidate(); + Assert.False(state.TryPublish(request, plate, report)); + Assert.Equal(OverlapCheckStatus.Stale, state.Status); + } + + [Fact] + public void EmptyCompleteReportAloneCanSayClear() + { + var plate = new Plate(); + var state = new OverlapReportState(); + Assert.Equal(OverlapCheckStatus.NotChecked, state.Status); + Assert.True(state.TryPublish(state.Begin(plate), plate, Analyze(plate))); + Assert.Equal("No material overlaps detected", state.Message); + } + + private static PlateOverlapReport Analyze(Plate plate) => PlateOverlapAnalyzer.Analyze(plate.Parts.ToArray()); + + 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.WinForms.Tests/PlateOverlapOverlayTests.cs b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs new file mode 100644 index 0000000..3085008 --- /dev/null +++ b/OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs @@ -0,0 +1,436 @@ +using System.Collections.Concurrent; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Runtime.ExceptionServices; +using System.Windows.Forms; +using OpenNest.CNC; +using OpenNest.Controls; +using OpenNest.Diagnostics; +using OpenNest.Forms; +using OpenNest.Geometry; + +namespace OpenNest.WinForms.Tests; + +[CollectionDefinition("Overlap overlay", DisableParallelization = true)] +public class OverlapOverlayCollection; + +[Collection("Overlap overlay")] +public class PlateOverlapOverlayTests +{ + [Fact] + public void RestartAndCancelRejectLateCompletionsOnTheUiThread() => RunSta(() => + { + using var run = new OverlayRun(); + var uiThread = Environment.CurrentManagedThreadId; + run.View.OverlapStateChanged += (_, _) => Assert.Equal(uiThread, Environment.CurrentManagedThreadId); + var first = run.Start(); + var old = run.Next(); + var second = run.Start(); + var current = run.Next(); + Assert.True(old.Token.IsCancellationRequested); + old.Complete(); + run.Pump(first); + Assert.Equal(OverlapCheckStatus.Checking, run.View.OverlapStatus); + current.Complete(); + run.Pump(second); + Assert.Equal(OverlapCheckStatus.Current, run.View.OverlapStatus); + Assert.Single(run.View.OverlapReport.Pairs); + + var canceled = run.Start(); + var late = run.Next(); + run.View.CancelOverlapCheck(); + Assert.True(late.Token.IsCancellationRequested); + late.Complete(); + run.Pump(canceled); + Assert.Equal(OverlapCheckStatus.Canceled, run.View.OverlapStatus); + Assert.Null(run.View.OverlapReport); + }); + + [Theory] + [InlineData("move")] + [InlineData("add")] + [InlineData("remove")] + [InlineData("replace")] + [InlineData("reorder")] + [InlineData("editor")] + [InlineData("plate")] + public void EditOrPlateSwitchRejectsPendingReport(string edit) => RunSta(() => + { + using var run = new OverlayRun(); + var task = run.Start(); + var work = run.Next(); + var plate = run.View.Plate; + switch (edit) + { + case "move": plate.Parts[0].Offset(1e-10, 0); break; + case "add": plate.Parts.Add(Rectangle()); break; + case "remove": plate.Parts.RemoveAt(0); break; + case "replace": plate.Parts[0] = Rectangle(); break; + case "reorder": (plate.Parts[0], plate.Parts[1]) = (plate.Parts[1], plate.Parts[0]); break; + case "editor": run.View.InvalidateOverlapCheck(); break; + case "plate": run.View.Plate = new Plate(); break; + } + if (edit != "move") + Assert.True(work.Token.IsCancellationRequested); + work.Complete(); + run.Pump(task); + Assert.True(work.Token.IsCancellationRequested); + Assert.Null(run.View.OverlapReport); + Assert.Equal(edit == "plate" ? OverlapCheckStatus.NotChecked : OverlapCheckStatus.Stale, + run.View.OverlapStatus); + }); + + [Fact] + public void PaintChecksFreshnessEvenWhenDisplayIsOff() => RunSta(() => + { + using var run = new OverlayRun(); + run.Finish(); + using var image = new Bitmap(240, 240); + using var graphics = Graphics.FromImage(image); + run.View.OverlapOverlay.Draw(graphics); + Assert.NotNull(run.View.OverlapOverlay.CachedPath); + run.View.OverlapDisplay = OverlapDisplayMode.Off; + run.View.Plate.Parts[0].Offset(1e-10, 0); + run.View.OverlapOverlay.Draw(graphics); + Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus); + Assert.Null(run.View.OverlapReport); + Assert.Null(run.View.OverlapOverlay.CachedPath); + }); + + [Fact] + public void DisplayPanZoomAndPreviewDoNotAnalyzeOrChangeCommittedParts() => RunSta(() => + { + using var run = new OverlayRun(); + var parts = run.View.Plate.Parts.ToArray(); + var locations = parts.Select(p => p.Location).ToArray(); + var programs = parts.Select(p => p.Program).ToArray(); + var quantities = parts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray(); + run.View.SelectAll(); + var selection = run.View.SelectedParts.ToArray(); + run.View.SetActiveParts(new List { Rectangle() }); + run.View.OverlapDisplay = OverlapDisplayMode.Off; + run.Finish(); + Assert.Equal(OverlapDisplayMode.Areas, run.View.OverlapDisplay); + Assert.Single(run.View.OverlapReport.Pairs); // Preview is not a third input. + var report = run.View.OverlapReport; + using var image = new Bitmap(240, 240); + using var graphics = Graphics.FromImage(image); + run.View.OverlapOverlay.Draw(graphics); + var path = run.View.OverlapOverlay.CachedPath; + Assert.Equal(FillMode.Winding, path.FillMode); + run.View.Pan(35, -20); + run.View.OverlapOverlay.Draw(graphics); + Assert.Same(path, run.View.OverlapOverlay.CachedPath); + run.View.OverlapDisplay = OverlapDisplayMode.Off; + run.View.OverlapDisplay = OverlapDisplayMode.Areas; + run.View.OverlapOverlay.Draw(graphics); + Assert.Same(path, run.View.OverlapOverlay.CachedPath); + run.View.ZoomToPoint(new Vector(), 2); + run.View.OverlapOverlay.Draw(graphics); + Assert.NotSame(path, run.View.OverlapOverlay.CachedPath); + Assert.Same(report, run.View.OverlapReport); + Assert.Equal(1, run.Calls); + Assert.Equal(parts, run.View.Plate.Parts.ToArray()); + Assert.Equal(locations, parts.Select(p => p.Location)); + Assert.Equal(programs, parts.Select(p => p.Program)); + Assert.Equal(quantities, parts.Select(p => p.BaseDrawing.Quantity.Nested)); + Assert.Equal(selection, run.View.SelectedParts); + }); + + [Fact] + public void TripleOverlapUsesOneFillAndGraphCoordinatesDoNotDoublePan() => RunSta(() => + { + using var run = new OverlayRun(); + run.View.Plate.Parts.Clear(); + run.View.Plate.Parts.Add(Rectangle()); + run.View.Plate.Parts.Add(Rectangle(2)); + run.View.Plate.Parts.Add(Rectangle(3)); + run.Finish(); + run.View.ZoomToPoint(new Vector(), 20); + run.View.SetOrigin(60, 220); + using var image = new Bitmap(300, 300); + using var graphics = Graphics.FromImage(image); + graphics.Clear(Color.White); + graphics.TranslateTransform(60, 220); + var transform = graphics.Transform.Elements; + run.View.OverlapOverlay.Draw(graphics); + Assert.Equal(transform, graphics.Transform.Elements); // Label restores graph space. + Assert.Equal(image.GetPixel(110, 180), image.GetPixel(130, 180)); // Double/triple material, same alpha. + Assert.NotEqual(Color.White.ToArgb(), image.GetPixel(130, 180).ToArgb()); + Assert.Equal(Color.White.ToArgb(), image.GetPixel(70, 180).ToArgb()); + }); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void HandleDestructionOrDisposalNeverResurrectsRequests(bool dispose) => RunSta(() => + { + using var run = new OverlayRun(); + run.Finish(); + using var image = new Bitmap(100, 100); + using var graphics = Graphics.FromImage(image); + run.View.OverlapOverlay.Draw(graphics); + Assert.NotNull(run.View.OverlapOverlay.CachedPath); + var task = run.Start(); + var work = run.Next(); + if (dispose) + run.View.Dispose(); + else + run.View.Recreate(); + Assert.True(work.Token.IsCancellationRequested); + Assert.Null(run.View.OverlapOverlay.CachedPath); + work.Complete(); + run.Pump(task); + Assert.Null(run.View.OverlapReport); + Assert.False(run.View.IsOverlapCheckRunning); + }); + + [Fact] + public void CompletedPathIsReleasedOnHandleRecreationAndCannotReappear() => RunSta(() => + { + using var run = new OverlayRun(); + run.Finish(); + using var image = new Bitmap(100, 100); + using var graphics = Graphics.FromImage(image); + run.View.OverlapOverlay.Draw(graphics); + Assert.NotNull(run.View.OverlapOverlay.CachedPath); + run.View.Recreate(); + Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus); + Assert.Null(run.View.OverlapOverlay.CachedPath); + run.View.OverlapOverlay.Draw(graphics); + Assert.Null(run.View.OverlapOverlay.CachedPath); + Assert.Null(run.View.OverlapReport); + Assert.Equal(1, run.Calls); + }); + + [Fact] + public void FailureAndIncompleteAreVisibleAndNeverReportedAsClear() => RunSta(() => + { + using var run = new OverlayRun(); + var task = run.Start(); + run.Next().Fail(); + run.Pump(task); + Assert.Equal(OverlapCheckStatus.Failed, run.View.OverlapStatus); + Assert.Contains("failed", run.View.Status); + Assert.Null(run.View.OverlapReport); + run.View.Plate.Parts.Add(new Part(new Drawing("invalid", new Program()))); + run.Finish(); + Assert.Equal(OverlapCheckStatus.Incomplete, run.View.OverlapStatus); + Assert.Contains("incomplete: 1 overlapping pairs; 1 parts", run.View.Status); + }); + + [Fact] + public void SnapshotKeepsOriginalLabelsWhenLiveNamesChange() => RunSta(() => + { + using var run = new OverlayRun(); + var task = run.Start(); + var work = run.Next(); + run.View.Plate.Parts[0].BaseDrawing.Name = "new label"; + work.Complete(); + run.Pump(task); + Assert.Equal("same name", Assert.Single(run.View.OverlapReport.Pairs).PartAName); + Assert.Equal(OverlapCheckStatus.Current, run.View.OverlapStatus); + }); + + [Theory] + [InlineData("add")] + [InlineData("remove")] + [InlineData("replace")] + [InlineData("clear")] + public void CollectionEventsImmediatelyDiscardCurrentReportAndPath(string edit) => RunSta(() => + { + using var run = new OverlayRun(); + run.Finish(); + using var image = new Bitmap(100, 100); + using var graphics = Graphics.FromImage(image); + run.View.OverlapOverlay.Draw(graphics); + 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; + } + Assert.Equal(OverlapCheckStatus.Stale, run.View.OverlapStatus); + Assert.Null(run.View.OverlapReport); + Assert.Null(run.View.OverlapOverlay.CachedPath); + }); + + [Fact] + public void MenusFollowMdiActivationDocumentDisplayAndRunningState() => RunSta(() => + { + using var host = new MenuHost(); + host.Show(); + var check = Menu(host, "mnuOverlapCheckActive"); + var cancel = Menu(host, "mnuOverlapCancel"); + var off = Menu(host, "mnuOverlapOff"); + var areas = Menu(host, "mnuOverlapAreas"); + Assert.False(check.Enabled); + Assert.False(cancel.Enabled); + Assert.False(off.Enabled); + Assert.False(areas.Enabled); + Assert.All(new[] { check, cancel, off, areas }, item => Assert.Equal(Keys.None, item.ShortcutKeys)); + + using var first = new EditNestForm(new Nest("first")) { MdiParent = host }; + 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.Show(); + Assert.True(areas.Checked); + Assert.False(off.Checked); + first.Activate(); + Assert.True(off.Checked); + Assert.False(areas.Checked); + Assert.Equal(OverlapDisplayMode.Areas, second.OverlapDisplay); + + var previous = SynchronizationContext.Current; + var context = new PumpContext(); + using var pending = new BlockingCollection(); + SynchronizationContext.SetSynchronizationContext(context); + try + { + first.PlateView.OverlapOverlay.Analyze = (snapshot, token) => + { + var request = new Work(snapshot, token); + pending.Add(request); + return request.WaitForResult(); + }; + var task = first.CheckOverlapsAsync(); + Assert.True(pending.TryTake(out var request, TimeSpan.FromSeconds(15))); + Assert.True(cancel.Enabled); + Assert.True(areas.Checked); // Checking from Off selects Areas. + cancel.PerformClick(); + Assert.False(cancel.Enabled); + Assert.True(request!.Token.IsCancellationRequested); + request.Complete(); + context.Pump(task); + Assert.Equal(OverlapCheckStatus.Canceled, first.PlateView.OverlapStatus); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previous); + } + first.Dispose(); + second.Dispose(); + Assert.False(check.Enabled); + Assert.False(cancel.Enabled); + }); + + private static ToolStripMenuItem Menu(MainForm host, string name) => + (ToolStripMenuItem)host.MainMenuStrip!.Items.Find(name, true).Single(); + + private sealed class MenuHost : MainForm + { + // Do not run startup migrations/automatic new-document creation or save test window settings. + protected override void OnLoad(EventArgs e) { } + protected override void OnClosing(System.ComponentModel.CancelEventArgs e) { } + } + + private sealed class TestView : PlateView + { + public void Pan(float x, float y) { origin.X += x; origin.Y += y; } + public void SetOrigin(float x, float y) => origin = new PointF(x, y); + public void Recreate() => RecreateHandle(); + } + + private sealed class OverlayRun : IDisposable + { + private readonly PumpContext context = new(); + private readonly SynchronizationContext? previous; + private readonly BlockingCollection work = new(); + public TestView View { get; } = new(); + public int Calls; + + public OverlayRun() + { + View.CreateControl(); + previous = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(context); + View.Plate.Parts.Add(Rectangle()); + View.Plate.Parts.Add(Rectangle(1)); + View.OverlapOverlay.Analyze = (snapshot, token) => + { + Interlocked.Increment(ref Calls); + var request = new Work(snapshot, token); + work.Add(request); + return request.WaitForResult(); + }; + } + + public Task Start() => View.CheckOverlapsAsync(); + public Work Next() + { + Assert.True(work.TryTake(out var result, TimeSpan.FromSeconds(15)), "Worker did not start."); + return result!; + } + public void Pump(Task task) => context.Pump(task); + public void Finish() + { + var task = Start(); + Next().Complete(); + Pump(task); + } + public void Dispose() + { + View.Dispose(); + SynchronizationContext.SetSynchronizationContext(previous); + work.Dispose(); + } + } + + private sealed class Work(PlateOverlapSnapshot snapshot, CancellationToken token) + { + public CancellationToken Token { get; } = token; + public TaskCompletionSource Result { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + // Only called by Task.Run workers; the STA supplies the result and pumps continuations. + public PlateOverlapReport WaitForResult() => Result.Task.GetAwaiter().GetResult(); + public void Complete() => Result.SetResult(PlateOverlapAnalyzer.Analyze(snapshot)); + public void Fail() => Result.SetException(new InvalidOperationException("test failure")); + } + + private sealed class PumpContext : SynchronizationContext + { + private readonly BlockingCollection callbacks = new(); + public override void Post(SendOrPostCallback callback, object? state) => callbacks.Add(() => callback(state)); + public void Pump(Task task) + { + while (!task.IsCompleted) + { + Assert.True(callbacks.TryTake(out var callback, TimeSpan.FromSeconds(15)), "UI continuation did not arrive."); + callback!(); + } + task.GetAwaiter().GetResult(); + } + } + + private static Part Rectangle(double x = 0) + { + 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), new Vector(x, 0)); + } + + private static void RunSta(System.Action action) + { + Exception? failure = null; + var thread = new Thread(() => + { + try { action(); } + catch (Exception ex) { failure = ex; } + }) + { IsBackground = true }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "The STA test did not complete."); + if (failure != null) + ExceptionDispatchInfo.Capture(failure).Throw(); + } +} diff --git a/OpenNest/Controls/OverlapOverlayController.cs b/OpenNest/Controls/OverlapOverlayController.cs new file mode 100644 index 0000000..61ef2cd --- /dev/null +++ b/OpenNest/Controls/OverlapOverlayController.cs @@ -0,0 +1,287 @@ +using System; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using OpenNest.Collections; +using OpenNest.Diagnostics; + +namespace OpenNest.Controls; + +/// +/// UI-thread owner of manual overlap requests and their cached display geometry. +/// Workers see only owned analyzer snapshots, never the live plate or view. +/// +internal sealed class OverlapOverlayController : IDisposable +{ + private readonly PlateView view; + private readonly OverlapReportState state = new(); + private ObservableList observedParts; + private CancellationTokenSource cancellation; + private GraphicsPath path; + private PlateOverlapReport pathReport; + private float pathScale; + private bool disposed; + + public OverlapOverlayController(PlateView view) => this.view = view; + + public event EventHandler StateChanged; + public OverlapCheckStatus Status => state.Status; + public PlateOverlapReport Report => state.Report; + public bool IsRunning => state.IsRunning; + + // Deterministic worker seam for STA lifecycle tests. Capture always stays on the UI thread. + internal Func Analyze { get; set; } + = PlateOverlapAnalyzer.Analyze; + internal GraphicsPath CachedPath => path; + + public OverlapDisplayMode DisplayMode + { + get => state.DisplayMode; + set + { + if (state.DisplayMode == value) + return; + state.DisplayMode = value; + NotifyChanged(); + } + } + + public void SetPlate(Plate plate) + { + Unsubscribe(); + CancelWorker(); + state.Reset(); + ReleasePath(); + observedParts = plate?.Parts; + if (observedParts != null) + { + observedParts.ItemAdded += PartAdded; + observedParts.ItemRemoved += PartRemoved; + observedParts.ItemChanged += PartChanged; + } + NotifyChanged(); + } + + public async Task CheckAsync() + { + if (!CanUseView || view.Plate == null) + return; + if (view.InvokeRequired) + throw new InvalidOperationException("Overlap checks must be started on the UI thread."); + + CancelWorker(); + var plate = view.Plate; + var generation = state.Begin(plate); + var source = new CancellationTokenSource(); + cancellation = source; + var token = source.Token; + ReleasePath(); + NotifyChanged(); + 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 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)) + CancelWorker(); // A pose/reference mismatch discovered at publication is stale. + NotifyChanged(); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + if (CanUseView && generation == state.Generation) + { + state.Cancel(); + NotifyChanged(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Overlap check failed: {ex}"); + if (CanUseView && generation == state.Generation) + { + if (!state.TryFail(generation, view.Plate)) + CancelWorker(); + NotifyChanged(); + } + } + finally + { + if (ReferenceEquals(cancellation, source)) + cancellation = null; + source.Dispose(); + } + } + + private bool CanUseView => !disposed && !view.IsDisposed && !view.Disposing && view.IsHandleCreated; + + public void Cancel() + { + if (!state.IsRunning) + return; + CancelWorker(); + state.Cancel(); + ReleasePath(); + NotifyChanged(); + } + + public void Invalidate() + { + var generation = state.Generation; + state.Invalidate(); + if (generation == state.Generation) + return; + CancelWorker(); + ReleasePath(); + NotifyChanged(); + } + + public void ReleaseHandle() + { + CancelWorker(); + state.Cancel(); + state.Invalidate(); + ReleasePath(); + NotifyChanged(); + } + + private void CancelWorker() + { + // The awaiting request disposes its own source after the worker exits. + var source = cancellation; + cancellation = null; + source?.Cancel(); + } + + private void NotifyChanged() + { + if (disposed || view.IsDisposed || view.Disposing) + return; + view.Status = state.Message; + view.Invalidate(); + StateChanged?.Invoke(this, EventArgs.Empty); + } + + private void PartAdded(object sender, ItemAddedEventArgs e) => Invalidate(); + private void PartRemoved(object sender, ItemRemovedEventArgs e) => Invalidate(); + private void PartChanged(object sender, ItemChangedEventArgs e) => Invalidate(); + + private void Unsubscribe() + { + if (observedParts == null) + return; + observedParts.ItemAdded -= PartAdded; + observedParts.ItemRemoved -= PartRemoved; + observedParts.ItemChanged -= PartChanged; + observedParts = null; + } + + public void Draw(Graphics graphics) + { + if (disposed) + return; + var generation = state.Generation; + state.EnsureFresh(view.Plate); + if (generation != state.Generation) + { + CancelWorker(); + ReleasePath(); + NotifyChanged(); + } + + if (state.DisplayMode == OverlapDisplayMode.Areas && state.Report != null) + { + EnsurePath(); + if (path.PointCount > 0) + { + using var brush = new SolidBrush(Color.FromArgb(100, 255, 0, 80)); + // A single winding fill is the union: triple overlap is not painted darker. + graphics.FillPath(brush, path); + } + } + DrawStateLabel(graphics); + } + + private void EnsurePath() + { + if (ReferenceEquals(pathReport, state.Report) && pathScale == view.ViewScale && path != null) + return; + ReleasePath(); + var next = new GraphicsPath(FillMode.Winding); + try + { + foreach (var pair in state.Report.Pairs) + { + foreach (var region in pair.Regions) + { + var vertices = region.Vertices; + var points = new PointF[vertices.Count - 1]; // Analyzer repeats the closing vertex. + var signedArea = 0.0; + var start = vertices[0]; + for (var i = 0; i < points.Length; i++) + { + var a = vertices[i] - start; + var b = vertices[i + 1] - start; + signedArea += a.X * b.Y - b.X * a.Y; + // The host has already translated by origin: graph, not control coordinates. + points[i] = view.PointWorldToGraph(vertices[i]); + } + if (signedArea < 0) + Array.Reverse(points); + next.AddPolygon(points); + } + } + path = next; + pathReport = state.Report; + pathScale = view.ViewScale; + } + catch + { + next.Dispose(); + throw; + } + } + + private void DrawStateLabel(Graphics graphics) + { + var saved = graphics.Save(); + try + { + graphics.ResetTransform(); + var text = state.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); + graphics.DrawString(text, view.Font, Brushes.DarkRed, 10, 9); + } + finally + { + graphics.Restore(saved); + } + } + + private void ReleasePath() + { + path?.Dispose(); + path = null; + pathReport = null; + } + + public void Dispose() + { + if (disposed) + return; + disposed = true; + Unsubscribe(); + CancelWorker(); + state.Reset(); + ReleasePath(); + } +} diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs index 928e8ad..7fb35ac 100644 --- a/OpenNest/Controls/PlateView.cs +++ b/OpenNest/Controls/PlateView.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using System.Windows.Forms; using OpenNest.Actions; using OpenNest.Collections; +using OpenNest.Diagnostics; using OpenNest.Engine; using OpenNest.Engine.Fill; using OpenNest.Engine.Jobs.Placement; @@ -23,6 +24,7 @@ namespace OpenNest.Controls public class PlateView : DrawControl { private readonly Timer redrawTimer; + private readonly OverlapOverlayController overlapOverlay; private string status; private Plate plate; @@ -86,6 +88,7 @@ namespace OpenNest.Controls public PlateView(ColorScheme colorScheme) { + overlapOverlay = new OverlapOverlayController(this); Plate = new Plate(60, 120); origin = new PointF(); parts = new List(); @@ -208,9 +211,28 @@ namespace OpenNest.Controls foreach (var part in plate.Parts) parts.Add(LayoutPart.Create(part, this)); + overlapOverlay.SetPlate(plate); actionManager?.OnPlateChanged(); } + internal OverlapOverlayController OverlapOverlay => overlapOverlay; + public OverlapCheckStatus OverlapStatus => overlapOverlay.Status; + public PlateOverlapReport OverlapReport => overlapOverlay.Report; + public bool IsOverlapCheckRunning => overlapOverlay.IsRunning; + public OverlapDisplayMode OverlapDisplay + { + get => overlapOverlay.DisplayMode; + set => overlapOverlay.DisplayMode = value; + } + public event EventHandler OverlapStateChanged + { + add => overlapOverlay.StateChanged += value; + remove => overlapOverlay.StateChanged -= value; + } + public Task CheckOverlapsAsync() => overlapOverlay.CheckAsync(); + public void CancelOverlapCheck() => overlapOverlay.Cancel(); + public void InvalidateOverlapCheck() => overlapOverlay.Invalidate(); + public string Status { get { return status; } @@ -491,6 +513,7 @@ namespace OpenNest.Controls renderer.DrawCutOffs(e.Graphics); renderer.DrawActiveWorkArea(e.Graphics); renderer.DrawDebugRemnants(e.Graphics); + DrawOverlapOverlay(e.Graphics); base.OnPaint(e); @@ -515,12 +538,22 @@ namespace OpenNest.Controls } } + private void DrawOverlapOverlay(Graphics graphics) => overlapOverlay.Draw(graphics); + protected override void OnHandleDestroyed(EventArgs e) { + overlapOverlay.ReleaseHandle(); base.OnHandleDestroyed(e); actionManager.Cleanup(); } + protected override void Dispose(bool disposing) + { + if (disposing) + overlapOverlay?.Dispose(); + base.Dispose(disposing); + } + public override void Refresh() { parts.ForEach(p => p.Update(this)); diff --git a/OpenNest/Forms/EditNestForm.cs b/OpenNest/Forms/EditNestForm.cs index 769e402..c8f86f0 100644 --- a/OpenNest/Forms/EditNestForm.cs +++ b/OpenNest/Forms/EditNestForm.cs @@ -10,6 +10,7 @@ using OpenNest.Api; using OpenNest.CNC.CuttingStrategy; using OpenNest.Collections; using OpenNest.Controls; +using OpenNest.Diagnostics; using OpenNest.Engine; using OpenNest.Engine.Sequencing; using OpenNest.IO; @@ -26,6 +27,15 @@ namespace OpenNest.Forms public readonly Document Document; public readonly PlateView PlateView; + + public System.Threading.Tasks.Task CheckOverlapsAsync() => PlateView.CheckOverlapsAsync(); + public void CancelOverlapCheck() => PlateView.CancelOverlapCheck(); + public OverlapDisplayMode OverlapDisplay + { + get => PlateView.OverlapDisplay; + set => PlateView.OverlapDisplay = value; + } + public readonly PlateManager PlateManager; public Nest Nest => Document.Nest; @@ -897,6 +907,8 @@ namespace OpenNest.Forms program => NestWriter.GetProgramText(program) + "\0" + NestWriter.GetSubProgramsText(program) ); var converter = new CadConverterForm(); + // LoadDrawings can edit live programs even when the dialog is canceled. + PlateView.InvalidateOverlapCheck(); converter.LoadDrawings(Nest.Drawings); if (converter.ShowDialog() != DialogResult.OK) diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs index b101cf9..3ff9260 100644 --- a/OpenNest/Forms/MainForm.Designer.cs +++ b/OpenNest/Forms/MainForm.Designer.cs @@ -48,6 +48,12 @@ toolStripMenuItem4 = new System.Windows.Forms.ToolStripSeparator(); mnuEditSelectAll = new System.Windows.Forms.ToolStripMenuItem(); mnuView = new System.Windows.Forms.ToolStripMenuItem(); + mnuViewOverlapCheck = new System.Windows.Forms.ToolStripMenuItem(); + mnuOverlapCheckActive = new System.Windows.Forms.ToolStripMenuItem(); + mnuOverlapCancel = new System.Windows.Forms.ToolStripMenuItem(); + mnuOverlapDisplay = new System.Windows.Forms.ToolStripMenuItem(); + mnuOverlapOff = new System.Windows.Forms.ToolStripMenuItem(); + mnuOverlapAreas = new System.Windows.Forms.ToolStripMenuItem(); mnuViewDrawRapids = new System.Windows.Forms.ToolStripMenuItem(); mnuViewDrawPiercePoints = new System.Windows.Forms.ToolStripMenuItem(); mnuViewDrawBounds = new System.Windows.Forms.ToolStripMenuItem(); @@ -318,10 +324,49 @@ // // mnuView // - mnuView.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuViewDrawRapids, mnuViewDrawPiercePoints, mnuViewDrawBounds, mnuViewDrawOffset, mnuViewDrawCutDirection, toolStripMenuItem5, mnuViewZoomTo, mnuViewZoomIn, mnuViewZoomOut }); + mnuView.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuViewDrawRapids, mnuViewDrawPiercePoints, mnuViewDrawBounds, mnuViewDrawOffset, mnuViewDrawCutDirection, mnuViewOverlapCheck, toolStripMenuItem5, mnuViewZoomTo, mnuViewZoomIn, mnuViewZoomOut }); mnuView.Name = "mnuView"; mnuView.Size = new System.Drawing.Size(44, 20); mnuView.Text = "&View"; + mnuView.DropDownOpening += OverlapMenu_Opening; + // + // mnuViewOverlapCheck + // + mnuViewOverlapCheck.Name = "mnuViewOverlapCheck"; + mnuViewOverlapCheck.Text = "Overlap Check"; + mnuViewOverlapCheck.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuOverlapCheckActive, mnuOverlapCancel, mnuOverlapDisplay }); + mnuViewOverlapCheck.DropDownOpening += OverlapMenu_Opening; + // + // mnuOverlapCheckActive + // + mnuOverlapCheckActive.Name = "mnuOverlapCheckActive"; + mnuOverlapCheckActive.Text = "Check Active Plate"; + mnuOverlapCheckActive.Click += CheckOverlaps_Click; + // + // mnuOverlapCancel + // + mnuOverlapCancel.Name = "mnuOverlapCancel"; + mnuOverlapCancel.Text = "Cancel Check"; + mnuOverlapCancel.Click += CancelOverlapCheck_Click; + // + // mnuOverlapDisplay + // + mnuOverlapDisplay.Name = "mnuOverlapDisplay"; + mnuOverlapDisplay.Text = "Display"; + mnuOverlapDisplay.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] { mnuOverlapOff, mnuOverlapAreas }); + mnuOverlapDisplay.DropDownOpening += OverlapMenu_Opening; + // + // mnuOverlapOff + // + mnuOverlapOff.Name = "mnuOverlapOff"; + mnuOverlapOff.Text = "Off"; + mnuOverlapOff.Click += OverlapOff_Click; + // + // mnuOverlapAreas + // + mnuOverlapAreas.Name = "mnuOverlapAreas"; + mnuOverlapAreas.Text = "Areas"; + mnuOverlapAreas.Click += OverlapAreas_Click; // // mnuViewDrawRapids // @@ -1241,6 +1286,12 @@ private System.Windows.Forms.ToolStripSeparator toolStripMenuItem4; private System.Windows.Forms.ToolStripMenuItem mnuEditSelectAll; private System.Windows.Forms.ToolStripMenuItem mnuView; + private System.Windows.Forms.ToolStripMenuItem mnuViewOverlapCheck; + private System.Windows.Forms.ToolStripMenuItem mnuOverlapCheckActive; + private System.Windows.Forms.ToolStripMenuItem mnuOverlapCancel; + private System.Windows.Forms.ToolStripMenuItem mnuOverlapDisplay; + private System.Windows.Forms.ToolStripMenuItem mnuOverlapOff; + private System.Windows.Forms.ToolStripMenuItem mnuOverlapAreas; private System.Windows.Forms.ToolStripMenuItem mnuViewDrawRapids; private System.Windows.Forms.ToolStripMenuItem mnuViewDrawPiercePoints; private System.Windows.Forms.ToolStripMenuItem mnuViewDrawBounds; diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index 38cb987..fb3736a 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -10,6 +10,7 @@ using System.Windows.Forms; using OpenNest.Actions; using OpenNest.Collections; using OpenNest.Data; +using OpenNest.Diagnostics; using OpenNest.Engine; using OpenNest.Engine.BestFit; using OpenNest.Engine.Fill; @@ -47,6 +48,7 @@ namespace OpenNest.Forms public MainForm() { InitializeComponent(); + UpdateOverlapMenu(); LoadSettings(); var renderer = new ToolStripRenderer(ToolbarTheme.Toolbar); @@ -438,6 +440,7 @@ namespace OpenNest.Forms activeForm.PlateView.MouseMove -= PlateView_MouseMove; activeForm.PlateView.MouseClick -= PlateView_MouseClick; activeForm.PlateView.StatusChanged -= PlateView_StatusChanged; + activeForm.PlateView.OverlapStateChanged -= OverlapStateChanged; activeForm.PlateView.SelectionChanged -= PlateView_SelectionChanged; activeForm.PlateView.PartAdded -= PlateView_PartAdded; activeForm.PlateView.PartRemoved -= PlateView_PartRemoved; @@ -450,6 +453,7 @@ namespace OpenNest.Forms } activeForm = ActiveMdiChild as EditNestForm; + UpdateOverlapMenu(); EnableCheck(); UpdatePlateStatus(); @@ -465,6 +469,7 @@ namespace OpenNest.Forms UpdateLocationMode(); UpdateSelectionStatus(); activeForm.PlateView.StatusChanged += PlateView_StatusChanged; + activeForm.PlateView.OverlapStateChanged += OverlapStateChanged; activeForm.PlateView.SelectionChanged += PlateView_SelectionChanged; activeForm.PlateView.PartAdded += PlateView_PartAdded; activeForm.PlateView.PartRemoved += PlateView_PartRemoved; @@ -658,6 +663,43 @@ namespace OpenNest.Forms #region View Menu Events + private void OverlapStateChanged(object sender, EventArgs e) => UpdateOverlapMenu(); + private void OverlapMenu_Opening(object sender, EventArgs e) => UpdateOverlapMenu(); + + private void UpdateOverlapMenu() + { + var hasPlate = activeForm != null && !activeForm.IsDisposed && activeForm.PlateView.Plate != null; + mnuViewOverlapCheck.Enabled = hasPlate; + mnuOverlapCheckActive.Enabled = hasPlate; + mnuOverlapCancel.Enabled = hasPlate && activeForm.PlateView.IsOverlapCheckRunning; + mnuOverlapDisplay.Enabled = hasPlate; + mnuOverlapOff.Enabled = hasPlate; + mnuOverlapAreas.Enabled = hasPlate; + mnuOverlapOff.Checked = hasPlate && activeForm.OverlapDisplay == OverlapDisplayMode.Off; + mnuOverlapAreas.Checked = hasPlate && activeForm.OverlapDisplay == OverlapDisplayMode.Areas; + } + + private async void CheckOverlaps_Click(object sender, EventArgs e) + { + var form = activeForm; + if (form != null) + await form.CheckOverlapsAsync(); + } + + private void CancelOverlapCheck_Click(object sender, EventArgs e) => activeForm?.CancelOverlapCheck(); + + private void OverlapOff_Click(object sender, EventArgs e) + { + if (activeForm != null) + activeForm.OverlapDisplay = OverlapDisplayMode.Off; + } + + private void OverlapAreas_Click(object sender, EventArgs e) + { + if (activeForm != null) + activeForm.OverlapDisplay = OverlapDisplayMode.Areas; + } + private void ToggleDrawRapids_Click(object sender, EventArgs e) { if (activeForm == null) diff --git a/OpenNest/Properties/AssemblyInfo.cs b/OpenNest/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..ba6e8c3 --- /dev/null +++ b/OpenNest/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("OpenNest.WinForms.Tests")] diff --git a/README.md b/README.md index 1666e6b..d14130a 100644 --- a/README.md +++ b/README.md @@ -12,6 +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, [automatic scrap cutoffs with unused-tail preservation](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, 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). ## Requirements diff --git a/docs/geometry/visual-overlap-check.md b/docs/geometry/visual-overlap-check.md index bbf99e6..2e013e0 100644 --- a/docs/geometry/visual-overlap-check.md +++ b/docs/geometry/visual-overlap-check.md @@ -1,9 +1,30 @@ -# Material-overlap polygon diagnostics +# Visual material-overlap check + +## Desktop use + +Choose **View > Overlap Check > Check Active Plate** to check committed parts on +this plate. Shared material is shaded red/magenta without changing the nest, +selection, cutting paths, or export behavior. Cutoffs and temporary preview parts +are excluded. **Cancel Check** discards the running request. **Display > Off / +Areas** changes visibility without rerunning analysis; checking from Off shows +Areas. Display preferences belong to the current document and are not saved. + +A persistent label distinguishes unchecked, checking, current, incomplete, stale, +canceled, and failed checks. Only a completed, current, fully checked report can +say **No material overlaps detected**. An incomplete check retains known overlaps +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. + +## Analysis API `OpenNest.Diagnostics.PlateOverlapAnalyzer` in OpenNest.Core checks a group of placed -parts and returns the shared polygon areas for each overlapping pair. This is the -cross-platform analysis foundation for a future PlateView overlay; it does not add -a menu command, painting, centroids, or freshness management yet. Existing +parts and returns the shared polygon areas for each overlapping pair. Existing `Part.Intersects`, `PartOverlapChecker`, `Plate.HasOverlappingParts`, engine validators, and CLI entry points are unchanged. A separate shared-triangulator fix uses translation-stable winding, correcting missed clockwise outlines/holes far @@ -121,10 +142,33 @@ slivers are below its reporting policy. Floating-point coordinates still have finite resolution. Contact and fragment thresholds are unchanged; only the shared triangulator's winding arithmetic was stabilized in the prerequisite fix. -Next integration/hardening: add PlateView request generations and stale-result -invalidation before rendering; measure real-plate capture/analysis cost and -cancellation latency before adding cached triangulations or background capture; -add area-weighted centroids separately if the UI needs them. +## Desktop lifecycle and rendering + +`OverlapReportState` and `OverlapGeometryStamp` in Core hold the testable request +policy. The stamp compares ordered part identities, exact pose scalars, drawing +and program references, cutoff status, and plate identity. It is not a geometry +hash: any new editor that mutates a clean program in place must call +`PlateView.InvalidateOverlapCheck()` before loading/mutation. Current live clean +program editing goes through `EditNestForm.EditDrawingsInConverter_Click`; +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 +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. + +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, +not ordinary repaints or panning. The state label saves/restores graphics state. + +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. ## Verification @@ -136,5 +180,21 @@ inputs, snapshot isolation, read-only output, cutting-program independence, and cancellation. Run: ```sh -dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter FullyQualifiedName~PlateOverlapAnalyzerTests +dotnet test OpenNest.Tests/OpenNest.Tests.csproj --filter 'FullyQualifiedName~PlateOverlapAnalyzerTests|FullyQualifiedName~OverlapReportStateTests' ``` + +`OverlapReportStateTests` verifies request supersession, exact pose/reference +freshness, stale clearing, cancellation, and incomplete-versus-clear messaging. +`OpenNest.WinForms.Tests/PlateOverlapOverlayTests.cs` adds STA worker/publication, +menu/MDI, path-cache, uniform-fill pixel, and control-lifetime checks. Run those +on Windows: + +```sh +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, +and repeated check/toggle/close cycles without GDI/disposed-control errors.