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,66 @@
using System;
using OpenNest.CNC;
namespace OpenNest.Diagnostics;
/// <summary>
/// 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.
/// </summary>
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;
}
}
@@ -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 }
/// <summary>UI-thread lifecycle policy, independent of workers, GDI and view transforms.</summary>
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<PlateOverlapIssue> issues)
{
var ids = new HashSet<int>();
foreach (var issue in issues)
{
ids.Add(issue.PartAId);
if (issue.PartBId.HasValue)
ids.Add(issue.PartBId.Value);
}
return ids.Count;
}
}
@@ -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));
}
}
@@ -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();
}
}
@@ -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;
/// <summary>
/// UI-thread owner of manual overlap requests and their cached display geometry.
/// Workers see only owned analyzer snapshots, never the live plate or view.
/// </summary>
internal sealed class OverlapOverlayController : IDisposable
{
private readonly PlateView view;
private readonly OverlapReportState state = new();
private ObservableList<Part> 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<PlateOverlapSnapshot, CancellationToken, PlateOverlapReport> 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<Part> e) => Invalidate();
private void PartRemoved(object sender, ItemRemovedEventArgs<Part> e) => Invalidate();
private void PartChanged(object sender, ItemChangedEventArgs<Part> 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();
}
}
+33
View File
@@ -10,6 +10,7 @@ using System.Threading.Tasks;
using System.Windows.Forms; using System.Windows.Forms;
using OpenNest.Actions; using OpenNest.Actions;
using OpenNest.Collections; using OpenNest.Collections;
using OpenNest.Diagnostics;
using OpenNest.Engine; using OpenNest.Engine;
using OpenNest.Engine.Fill; using OpenNest.Engine.Fill;
using OpenNest.Engine.Jobs.Placement; using OpenNest.Engine.Jobs.Placement;
@@ -23,6 +24,7 @@ namespace OpenNest.Controls
public class PlateView : DrawControl public class PlateView : DrawControl
{ {
private readonly Timer redrawTimer; private readonly Timer redrawTimer;
private readonly OverlapOverlayController overlapOverlay;
private string status; private string status;
private Plate plate; private Plate plate;
@@ -86,6 +88,7 @@ namespace OpenNest.Controls
public PlateView(ColorScheme colorScheme) public PlateView(ColorScheme colorScheme)
{ {
overlapOverlay = new OverlapOverlayController(this);
Plate = new Plate(60, 120); Plate = new Plate(60, 120);
origin = new PointF(); origin = new PointF();
parts = new List<LayoutPart>(); parts = new List<LayoutPart>();
@@ -208,9 +211,28 @@ namespace OpenNest.Controls
foreach (var part in plate.Parts) foreach (var part in plate.Parts)
parts.Add(LayoutPart.Create(part, this)); parts.Add(LayoutPart.Create(part, this));
overlapOverlay.SetPlate(plate);
actionManager?.OnPlateChanged(); 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 public string Status
{ {
get { return status; } get { return status; }
@@ -491,6 +513,7 @@ namespace OpenNest.Controls
renderer.DrawCutOffs(e.Graphics); renderer.DrawCutOffs(e.Graphics);
renderer.DrawActiveWorkArea(e.Graphics); renderer.DrawActiveWorkArea(e.Graphics);
renderer.DrawDebugRemnants(e.Graphics); renderer.DrawDebugRemnants(e.Graphics);
DrawOverlapOverlay(e.Graphics);
base.OnPaint(e); base.OnPaint(e);
@@ -515,12 +538,22 @@ namespace OpenNest.Controls
} }
} }
private void DrawOverlapOverlay(Graphics graphics) => overlapOverlay.Draw(graphics);
protected override void OnHandleDestroyed(EventArgs e) protected override void OnHandleDestroyed(EventArgs e)
{ {
overlapOverlay.ReleaseHandle();
base.OnHandleDestroyed(e); base.OnHandleDestroyed(e);
actionManager.Cleanup(); actionManager.Cleanup();
} }
protected override void Dispose(bool disposing)
{
if (disposing)
overlapOverlay?.Dispose();
base.Dispose(disposing);
}
public override void Refresh() public override void Refresh()
{ {
parts.ForEach(p => p.Update(this)); parts.ForEach(p => p.Update(this));
+12
View File
@@ -10,6 +10,7 @@ using OpenNest.Api;
using OpenNest.CNC.CuttingStrategy; using OpenNest.CNC.CuttingStrategy;
using OpenNest.Collections; using OpenNest.Collections;
using OpenNest.Controls; using OpenNest.Controls;
using OpenNest.Diagnostics;
using OpenNest.Engine; using OpenNest.Engine;
using OpenNest.Engine.Sequencing; using OpenNest.Engine.Sequencing;
using OpenNest.IO; using OpenNest.IO;
@@ -26,6 +27,15 @@ namespace OpenNest.Forms
public readonly Document Document; public readonly Document Document;
public readonly PlateView PlateView; 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 readonly PlateManager PlateManager;
public Nest Nest => Document.Nest; public Nest Nest => Document.Nest;
@@ -897,6 +907,8 @@ namespace OpenNest.Forms
program => NestWriter.GetProgramText(program) + "\0" + NestWriter.GetSubProgramsText(program) program => NestWriter.GetProgramText(program) + "\0" + NestWriter.GetSubProgramsText(program)
); );
var converter = new CadConverterForm(); var converter = new CadConverterForm();
// LoadDrawings can edit live programs even when the dialog is canceled.
PlateView.InvalidateOverlapCheck();
converter.LoadDrawings(Nest.Drawings); converter.LoadDrawings(Nest.Drawings);
if (converter.ShowDialog() != DialogResult.OK) if (converter.ShowDialog() != DialogResult.OK)
+52 -1
View File
@@ -48,6 +48,12 @@
toolStripMenuItem4 = new System.Windows.Forms.ToolStripSeparator(); toolStripMenuItem4 = new System.Windows.Forms.ToolStripSeparator();
mnuEditSelectAll = new System.Windows.Forms.ToolStripMenuItem(); mnuEditSelectAll = new System.Windows.Forms.ToolStripMenuItem();
mnuView = 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(); mnuViewDrawRapids = new System.Windows.Forms.ToolStripMenuItem();
mnuViewDrawPiercePoints = new System.Windows.Forms.ToolStripMenuItem(); mnuViewDrawPiercePoints = new System.Windows.Forms.ToolStripMenuItem();
mnuViewDrawBounds = new System.Windows.Forms.ToolStripMenuItem(); mnuViewDrawBounds = new System.Windows.Forms.ToolStripMenuItem();
@@ -318,10 +324,49 @@
// //
// mnuView // 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.Name = "mnuView";
mnuView.Size = new System.Drawing.Size(44, 20); mnuView.Size = new System.Drawing.Size(44, 20);
mnuView.Text = "&View"; 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 // mnuViewDrawRapids
// //
@@ -1241,6 +1286,12 @@
private System.Windows.Forms.ToolStripSeparator toolStripMenuItem4; private System.Windows.Forms.ToolStripSeparator toolStripMenuItem4;
private System.Windows.Forms.ToolStripMenuItem mnuEditSelectAll; private System.Windows.Forms.ToolStripMenuItem mnuEditSelectAll;
private System.Windows.Forms.ToolStripMenuItem mnuView; 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 mnuViewDrawRapids;
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawPiercePoints; private System.Windows.Forms.ToolStripMenuItem mnuViewDrawPiercePoints;
private System.Windows.Forms.ToolStripMenuItem mnuViewDrawBounds; private System.Windows.Forms.ToolStripMenuItem mnuViewDrawBounds;
+42
View File
@@ -10,6 +10,7 @@ using System.Windows.Forms;
using OpenNest.Actions; using OpenNest.Actions;
using OpenNest.Collections; using OpenNest.Collections;
using OpenNest.Data; using OpenNest.Data;
using OpenNest.Diagnostics;
using OpenNest.Engine; using OpenNest.Engine;
using OpenNest.Engine.BestFit; using OpenNest.Engine.BestFit;
using OpenNest.Engine.Fill; using OpenNest.Engine.Fill;
@@ -47,6 +48,7 @@ namespace OpenNest.Forms
public MainForm() public MainForm()
{ {
InitializeComponent(); InitializeComponent();
UpdateOverlapMenu();
LoadSettings(); LoadSettings();
var renderer = new ToolStripRenderer(ToolbarTheme.Toolbar); var renderer = new ToolStripRenderer(ToolbarTheme.Toolbar);
@@ -438,6 +440,7 @@ namespace OpenNest.Forms
activeForm.PlateView.MouseMove -= PlateView_MouseMove; activeForm.PlateView.MouseMove -= PlateView_MouseMove;
activeForm.PlateView.MouseClick -= PlateView_MouseClick; activeForm.PlateView.MouseClick -= PlateView_MouseClick;
activeForm.PlateView.StatusChanged -= PlateView_StatusChanged; activeForm.PlateView.StatusChanged -= PlateView_StatusChanged;
activeForm.PlateView.OverlapStateChanged -= OverlapStateChanged;
activeForm.PlateView.SelectionChanged -= PlateView_SelectionChanged; activeForm.PlateView.SelectionChanged -= PlateView_SelectionChanged;
activeForm.PlateView.PartAdded -= PlateView_PartAdded; activeForm.PlateView.PartAdded -= PlateView_PartAdded;
activeForm.PlateView.PartRemoved -= PlateView_PartRemoved; activeForm.PlateView.PartRemoved -= PlateView_PartRemoved;
@@ -450,6 +453,7 @@ namespace OpenNest.Forms
} }
activeForm = ActiveMdiChild as EditNestForm; activeForm = ActiveMdiChild as EditNestForm;
UpdateOverlapMenu();
EnableCheck(); EnableCheck();
UpdatePlateStatus(); UpdatePlateStatus();
@@ -465,6 +469,7 @@ namespace OpenNest.Forms
UpdateLocationMode(); UpdateLocationMode();
UpdateSelectionStatus(); UpdateSelectionStatus();
activeForm.PlateView.StatusChanged += PlateView_StatusChanged; activeForm.PlateView.StatusChanged += PlateView_StatusChanged;
activeForm.PlateView.OverlapStateChanged += OverlapStateChanged;
activeForm.PlateView.SelectionChanged += PlateView_SelectionChanged; activeForm.PlateView.SelectionChanged += PlateView_SelectionChanged;
activeForm.PlateView.PartAdded += PlateView_PartAdded; activeForm.PlateView.PartAdded += PlateView_PartAdded;
activeForm.PlateView.PartRemoved += PlateView_PartRemoved; activeForm.PlateView.PartRemoved += PlateView_PartRemoved;
@@ -658,6 +663,43 @@ namespace OpenNest.Forms
#region View Menu Events #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) private void ToggleDrawRapids_Click(object sender, EventArgs e)
{ {
if (activeForm == null) if (activeForm == null)
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("OpenNest.WinForms.Tests")]
+1
View File
@@ -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. - **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. - **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. - **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). - **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 ## Requirements
+69 -9
View File
@@ -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 `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 parts and returns the shared polygon areas for each overlapping pair. Existing
cross-platform analysis foundation for a future PlateView overlay; it does not add
a menu command, painting, centroids, or freshness management yet. Existing
`Part.Intersects`, `PartOverlapChecker`, `Plate.HasOverlappingParts`, engine `Part.Intersects`, `PartOverlapChecker`, `Plate.HasOverlappingParts`, engine
validators, and CLI entry points are unchanged. A separate shared-triangulator fix validators, and CLI entry points are unchanged. A separate shared-triangulator fix
uses translation-stable winding, correcting missed clockwise outlines/holes far 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 finite resolution. Contact and fragment thresholds are unchanged; only the shared
triangulator's winding arithmetic was stabilized in the prerequisite fix. triangulator's winding arithmetic was stabilized in the prerequisite fix.
Next integration/hardening: add PlateView request generations and stale-result ## Desktop lifecycle and rendering
invalidation before rendering; measure real-plate capture/analysis cost and
cancellation latency before adding cached triangulations or background capture; `OverlapReportState` and `OverlapGeometryStamp` in Core hold the testable request
add area-weighted centroids separately if the UI needs them. 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 ## Verification
@@ -136,5 +180,21 @@ inputs, snapshot isolation, read-only output, cutting-program independence, and
cancellation. Run: cancellation. Run:
```sh ```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.