Files
OpenNest/OpenNest.Tests/Diagnostics/IncrementalOverlapAnalysisTests.cs
T
aj 80d01839bd feat(diagnostics): place overlap markers on the shared material
The overlap crosshair sat on the pair's area centroid. For a split,
holed or concave overlap, that point can be off the red material:
between two patches, in a hole, or inside a curve.

PlateOverlapPair.Marker is a point inside the pair's largest island.
It is the island's pole of inaccessibility (PolyLabel), searched to a
tenth of the island's area/perimeter within a 4096-cell budget. If
that bounded search does not land inside (a long thin sliver), the
marker falls back to the midpoint of the widest interior run of a
horizontal scan line placed between vertex heights.

The overlay crosshair, marker hit testing, hover coordinates
("Marker:") and the cutting-inspection ring now use the marker.
Centroid stays the true area centroid. View > Overlap Check > Display
> Centroids is now labelled Markers.

Before the marker existed, its call sites used the centroid. Two
patches, a covered frame, a half annulus and a thin crescent then put
it off the material; now all four land on it. Disabling the scan-line
fallback fails its test. On the 389-plate corpus, markers add about
0.4 ms per overlapping pair; pair and area results are unchanged.
2026-10-15 23:31:14 -04:00

169 lines
8.5 KiB
C#

using OpenNest.CNC;
using OpenNest.Diagnostics;
using OpenNest.Geometry;
namespace OpenNest.Tests.Diagnostics;
public class IncrementalOverlapAnalysisTests
{
[Theory]
[InlineData("move-onto")]
[InlineData("move-away")]
[InlineData("nudge-within")]
[InlineData("rotate")]
[InlineData("delete-first")]
[InlineData("insert-first")]
[InlineData("swap")]
[InlineData("rename")]
[InlineData("duplicate")]
[InlineData("nothing")]
public void IncrementalRecheckMatchesFullAnalysisExactly(string edit)
{
var drawing = Square("sq", 4);
var holed = Holed("holed");
var parts = new List<Part>
{
new(drawing, new Vector(0, 0)),
new(drawing, new Vector(3, 0)), // overlaps part 0
new(holed, new Vector(20, 0)),
new(drawing, new Vector(22, 2)), // sits inside the hole: no overlap
new(drawing, new Vector(40, 0)),
new(drawing, new Vector(42, 1)), // overlaps part 4
};
var cache = new OverlapMaterialCache();
var previous = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache));
Assert.Equal(2, previous.Pairs.Count);
switch (edit)
{
case "move-onto": parts[3].Offset(-1.5, 0); break; // now hits hole material
case "move-away": parts[1].Offset(10, 0); break; // clears pair 0/1
case "nudge-within": parts[5].Offset(0.25, 0.25); break; // same pair, new area
case "rotate": parts[1].Rotate(0.3, parts[1].Location); break;
case "delete-first": parts.RemoveAt(0); break; // renumbers every pair
case "insert-first": parts.Insert(0, new Part(drawing, new Vector(41, 0))); break;
case "swap": (parts[0], parts[1]) = (parts[1], parts[0]); break; // operand order flips
case "rename": parts[0].BaseDrawing.Name = "renamed"; break;
case "duplicate": parts.Add(new Part(drawing, new Vector(0, 0))); break; // coincident copy
}
var incremental = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache), previous);
var oracle = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts));
Assert.Equal(Describe(oracle), Describe(incremental));
}
[Fact]
public void UnchangedPairsAreReusedAndOnlyTheMovedPartsNeighborsAreRecomputed()
{
var drawing = Square("sq", 4);
var parts = new List<Part> { new(drawing, new Vector(0, 0)), new(drawing, new Vector(3, 0)),
new(drawing, new Vector(40, 0)), new(drawing, new Vector(43, 0)) };
var cache = new OverlapMaterialCache();
var previous = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache));
parts[3].Offset(0.5, 0);
var incremental = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache), previous);
// The untouched pair's immutable geometry is shared; the moved pair is new.
Assert.Same(previous.Pairs[0].Regions, incremental.Pairs[0].Regions);
Assert.NotSame(previous.Pairs[1].Regions, incremental.Pairs[1].Regions);
Assert.Equal(0.5 * 4, incremental.Pairs[1].Area, 9); // x 40..44 vs 43.5..47.5
}
[Fact]
public void PairIssuesAreReusedAndRenumberedLikePairs()
{
// Two copies of a drawing at a pose whose pair clipping fails are reported as a pair issue;
// an unrelated deletion before them must keep that issue, renumbered.
var drawing = Square("sq", 4);
var parts = new List<Part> { new(drawing, new Vector(100, 0)), new(drawing, new Vector(0, 0)),
new(drawing, new Vector(2, 0)) };
var cache = new OverlapMaterialCache();
var previous = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache));
var issue = new PlateOverlapIssue(1, 2, "synthetic pair failure");
var withIssue = WithIssue(previous, issue);
parts.RemoveAt(0);
var incremental = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache), withIssue);
Assert.Equal(new PlateOverlapIssue(0, 1, "synthetic pair failure"), Assert.Single(incremental.Issues));
}
[Fact]
public void BaselineFromAnotherCacheOrSnapshotIsNotReused()
{
var drawing = Square("sq", 4);
var parts = new[] { new Part(drawing, new Vector(0, 0)), new Part(drawing, new Vector(3, 0)) };
var previous = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, new OverlapMaterialCache()));
var incremental = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, new OverlapMaterialCache()), previous);
Assert.NotSame(previous.Pairs[0].Regions, incremental.Pairs[0].Regions);
Assert.Equal(Describe(previous), Describe(incremental));
}
[Fact]
public void CacheReusesPreparedMaterialUntilClearedOrTheProgramVisiblyChanges()
{
var drawing = Square("sq", 4);
var parts = new[] { new Part(drawing, new Vector(0, 0)), new Part(drawing, new Vector(3, 0)) };
var cache = new OverlapMaterialCache();
var first = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache));
Assert.Equal(4, first.Pairs[0].Area, 9);
// An in-place edit that keeps the code count is invisible to the cache: callers must Clear.
drawing.Program.Codes[2] = new LinearMove(4, 8);
drawing.Program.Codes[3] = new LinearMove(0, 8);
Assert.Equal(4, PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache)).Pairs[0].Area, 9);
cache.Clear();
Assert.Equal(8, PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache)).Pairs[0].Area, 9);
// A changed code count is detected without Clear (no stale reuse for obvious edits).
drawing.Program.Codes.Clear();
var broken = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache));
Assert.Empty(broken.Pairs);
Assert.False(broken.IsComplete);
}
[Fact]
public void CanceledPreparationIsNotCachedAsAFailure()
{
var drawing = Holed("holed");
var parts = new[] { new Part(drawing, new Vector(0, 0)), new Part(drawing, new Vector(1, 0)) };
var cache = new OverlapMaterialCache();
var snapshot = PlateOverlapAnalyzer.Capture(parts, cache);
using var canceled = new CancellationTokenSource();
canceled.Cancel();
Assert.ThrowsAny<OperationCanceledException>(() => PlateOverlapAnalyzer.Analyze(snapshot, canceled.Token));
var report = PlateOverlapAnalyzer.Analyze(PlateOverlapAnalyzer.Capture(parts, cache));
Assert.True(report.IsComplete);
Assert.Single(report.Pairs);
}
// Same baseline snapshot, but its only result is a pair issue (real pair failures need
// numeric edge cases); reuse must carry the issue rather than recompute a pair.
private static PlateOverlapReport WithIssue(PlateOverlapReport report, PlateOverlapIssue issue) =>
new(new List<PlateOverlapPair>(), new List<PlateOverlapIssue> { issue }, report.Snapshot);
private static string Describe(PlateOverlapReport report) =>
string.Join(";", report.Pairs.Select(pair =>
$"{pair.PartAId}/{pair.PartBId}[{pair.PartAName}|{pair.PartBName}]:{pair.Area:R}:"
+ $"{pair.Centroid.X:R},{pair.Centroid.Y:R}:{pair.Marker.X:R},{pair.Marker.Y:R}:"
+ string.Join(",", pair.Regions.SelectMany(region => region.Vertices
.Concat(region.Holes.SelectMany(hole => hole))).Select(v => $"{v.X:R} {v.Y:R}"))))
+ "|" + string.Join(";", report.Issues.Select(issue => $"{issue.PartAId}/{issue.PartBId}:{issue.Message}"));
private static Drawing Square(string name, double size) =>
new(name, Program(new[] { new Vector(0, 0), new Vector(size, 0), new Vector(size, size), new Vector(0, size) }));
private static Drawing Holed(string name) => new(name, Program(
new[] { new Vector(0, 0), new Vector(10, 0), new Vector(10, 10), new Vector(0, 10) },
new[] { new Vector(1, 1), new Vector(7, 1), new Vector(7, 7), new Vector(1, 7) }));
private static Program Program(params Vector[][] contours)
{
var program = new Program(Mode.Absolute);
foreach (var contour in contours)
{
program.Codes.Add(new RapidMove(contour[0]));
foreach (var point in contour.Skip(1).Append(contour[0]))
program.Codes.Add(new LinearMove(point));
}
return program;
}
}