feat(plateview): show material overlap areas

This commit is contained in:
aj
2026-09-28 21:35:05 -04:00
parent 76044f6bd9
commit e4d07121c8
12 changed files with 1319 additions and 10 deletions
@@ -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<Part> { 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<Work>();
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> 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<PlateOverlapReport> 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<System.Action> 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();
}
}