Files
OpenNest/OpenNest.Tests/Diagnostics/IncrementalOverlapAnalysisTests.cs
T
aj 99c31748ba feat(diagnostics): cache overlap material and recheck incrementally
PlateOverlapAnalyzer.Capture(parts, OverlapMaterialCache) reuses each clean
program's converted entities and prepared material across requests, and
Analyze(snapshot, previous) reuses pair results whose two parts kept the
same source, exact pose and relative order, renumbering them. A recheck
after moving one part only clips that part's neighbors again.

On 501 real PEP plates: full check median 1 ms / max 6.4 s; incremental
recheck after one move median 0.1 ms / max 38 ms. Incremental results
matched uncached full analysis exactly across 2505 edits.
2026-09-29 09:31:26 -04:00

168 lines
8.4 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}:"
+ string.Join(",", pair.Regions.SelectMany(region => region.Vertices).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;
}
}