feat(plateview): show material overlap areas
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<LayoutPart>();
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+52
-1
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("OpenNest.WinForms.Tests")]
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user