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.
This commit is contained in:
aj
2026-09-29 09:31:26 -04:00
parent 2b8a3ce7db
commit 99c31748ba
4 changed files with 395 additions and 39 deletions
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Diagnostics;
/// <summary>
/// Reuses each clean drawing program's converted entities and prepared (validated, chorded,
/// triangulation-checked) material across overlap requests. Preparation dominates the cost of
/// drawings with many holes, and it depends only on the drawing, not on where parts sit, so a
/// recheck after moving parts only repeats the cheap pose transforms and pair clipping.
/// Entries are keyed by <see cref="Program"/> reference and released with it. A changed code
/// count or program rotation is detected, but that is not a geometry hash: call
/// <see cref="Clear"/> before any in-place edit of a clean program or its hole subprograms.
/// Capture on one thread at a time; prepared entries may be shared by concurrent analyses.
/// </summary>
public sealed class OverlapMaterialCache
{
private readonly ConditionalWeakTable<Program, OverlapSource> sources = new();
public void Clear() => sources.Clear();
internal OverlapSource Get(Program program, Func<Program, OverlapSource> create)
{
if (sources.TryGetValue(program, out var source) && source.Matches(program))
return source;
source = create(program);
sources.AddOrUpdate(program, source);
return source;
}
}
/// <summary>Owned converted entities for one clean program, plus its lazily prepared material.</summary>
internal sealed class OverlapSource
{
private readonly object gate = new();
private readonly int codeCount;
private readonly long rotation;
private PreparedMaterial prepared;
internal OverlapSource(Program program, List<Entity> entities, string error)
{
codeCount = program.Codes.Count;
rotation = BitConverter.DoubleToInt64Bits(program.Rotation);
Entities = entities;
Error = error;
}
/// <summary>Never mutated; preparation clones before chaining.</summary>
internal List<Entity> Entities { get; }
internal string Error { get; }
internal bool Matches(Program program) =>
program.Codes.Count == codeCount && BitConverter.DoubleToInt64Bits(program.Rotation) == rotation;
/// <summary>
/// Prepares once and shares the result. A geometry failure is cached like a success;
/// cancellation is not, so a superseded request cannot poison the next one.
/// </summary>
internal PreparedMaterial Prepare(CancellationToken cancellationToken)
{
var current = Volatile.Read(ref prepared);
if (current != null)
return current;
lock (gate)
{
if (prepared != null)
return prepared;
PreparedMaterial result;
try
{
result = new PreparedMaterial(OverlapMaterial.Read(Entities, cancellationToken), null);
}
catch (Exception exception) when (PlateOverlapAnalyzer.IsGeometryFailure(exception))
{
result = new PreparedMaterial(null, exception.Message);
}
Volatile.Write(ref prepared, result);
return result;
}
}
}
/// <summary>Validated local-frame material, only ever read (transformed into new polygons).</summary>
internal sealed record PreparedMaterial(OverlapMaterial Material, string Error);
+116 -37
View File
@@ -21,13 +21,22 @@ public static class PlateOverlapAnalyzer
/// Inputs must not change during capture. Later analysis never reads live domain objects.
/// </summary>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts,
CancellationToken cancellationToken = default) =>
Capture(parts, new OverlapMaterialCache(), cancellationToken);
/// <summary>
/// As <see cref="Capture(IReadOnlyList{Part}, CancellationToken)"/>, but reuses converted
/// and prepared drawing material from <paramref name="cache"/> across requests. Clear the
/// cache before any in-place clean-program edit (see <see cref="OverlapMaterialCache"/>).
/// </summary>
public static PlateOverlapSnapshot Capture(IReadOnlyList<Part> parts, OverlapMaterialCache cache,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(parts);
ArgumentNullException.ThrowIfNull(cache);
cancellationToken.ThrowIfCancellationRequested();
var captured = new List<CapturedOverlapPart>();
var issues = new List<PlateOverlapIssue>();
var sources = new Dictionary<Program, CapturedSource>(ReferenceEqualityComparer.Instance);
for (var id = 0; id < parts.Count; id++)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -43,28 +52,11 @@ public static class PlateOverlapAnalyzer
var location = part.Location;
if (!double.IsFinite(rotation) || !OverlapMaterial.IsFinite(location))
throw new ArgumentException("Part pose must be finite.");
if (!sources.TryGetValue(program, out var source))
{
try
{
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
// Conversion creates fresh geometry, including expanded shared hole calls;
// no cloning/rotation of a live program or subprogram is necessary.
source = new CapturedSource(ConvertProgram.ToGeometry(program)
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)
&& entity.Layer != SpecialLayers.Leadin
&& entity.Layer != SpecialLayers.Leadout).ToList(), null);
}
catch (Exception exception) when (IsGeometryFailure(exception))
{
source = new CapturedSource(null, exception.Message);
}
sources.Add(program, source);
}
var source = cache.Get(program, CaptureSource);
if (source.Error != null)
throw new ArgumentException(source.Error);
captured.Add(new CapturedOverlapPart(id, part.BaseDrawing.Name,
source.Entities, rotation, location));
source, rotation, location));
}
catch (Exception exception) when (IsGeometryFailure(exception))
{
@@ -75,6 +67,24 @@ public static class PlateOverlapAnalyzer
return new PlateOverlapSnapshot(captured, issues);
}
private static OverlapSource CaptureSource(Program program)
{
try
{
ValidateProgram(program, new HashSet<Program>(ReferenceEqualityComparer.Instance));
// Conversion creates fresh geometry, including expanded shared hole calls;
// no cloning/rotation of a live program or subprogram is necessary.
return new OverlapSource(program, ConvertProgram.ToGeometry(program)
.Where(entity => SpecialLayers.IsMaterial(entity.Layer)
&& entity.Layer != SpecialLayers.Leadin
&& entity.Layer != SpecialLayers.Leadout).ToList(), null);
}
catch (Exception exception) when (IsGeometryFailure(exception))
{
return new OverlapSource(program, null, exception.Message);
}
}
/// <summary>Convenience synchronous capture and analysis of a group of parts.</summary>
public static PlateOverlapReport Analyze(IReadOnlyList<Part> parts,
CancellationToken cancellationToken = default) =>
@@ -85,31 +95,33 @@ public static class PlateOverlapAnalyzer
/// Cancellation throws and publishes no partial report. Check IsComplete before claiming clear.
/// </summary>
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot,
CancellationToken cancellationToken = default) =>
Analyze(snapshot, null, cancellationToken);
/// <summary>
/// Incremental recheck: identical to a full analysis of <paramref name="snapshot"/>, but a
/// pair whose two parts are unchanged since <paramref name="previous"/> (same captured source
/// and bit-identical pose, in the same relative order) reuses that report's result instead
/// of being clipped again. After moving one part only its own neighbors are recomputed.
/// Reuse needs sources shared through one <see cref="OverlapMaterialCache"/>; otherwise every
/// pair is recomputed. Null <paramref name="previous"/> performs a full analysis.
/// </summary>
public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot, PlateOverlapReport previous,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(snapshot);
cancellationToken.ThrowIfCancellationRequested();
var reuse = PairReuse.Create(previous, snapshot);
var issues = snapshot.Issues.ToList();
var pairs = new List<PlateOverlapPair>();
var prepared = new List<PreparedPart>();
var sources = new Dictionary<List<Entity>, PreparedSource>(ReferenceEqualityComparer.Instance);
foreach (var part in snapshot.Parts)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
if (!sources.TryGetValue(part.Entities, out var source))
{
try
{
source = new PreparedSource(OverlapMaterial.Read(part.Entities, cancellationToken), null);
}
catch (Exception exception) when (IsGeometryFailure(exception))
{
source = new PreparedSource(null, exception.Message);
}
sources.Add(part.Entities, source);
}
// Prepared material is shared by every part and request using this source.
var source = part.Source.Prepare(cancellationToken);
if (source.Error != null)
throw new ArgumentException(source.Error);
var material = source.Material.Transform(part.Rotation, part.Location);
@@ -139,6 +151,8 @@ public static class PlateOverlapAnalyzer
continue;
var a = first.Input.Id < second.Input.Id ? first : second;
var b = first.Input.Id < second.Input.Id ? second : first;
if (reuse != null && reuse.TryReuse(a.Input, b.Input, pairs, issues))
continue;
try
{
// Keep pair clipping arithmetic near the parts where possible, then
@@ -180,10 +194,77 @@ public static class PlateOverlapAnalyzer
cancellationToken.ThrowIfCancellationRequested();
return new PlateOverlapReport(pairs.OrderBy(pair => pair.PartAId)
.ThenBy(pair => pair.PartBId).ToList(), issues.OrderBy(issue => issue.PartAId)
.ThenBy(issue => issue.PartBId).ToList());
.ThenBy(issue => issue.PartBId).ToList(), snapshot);
}
private static bool IsGeometryFailure(Exception exception) => exception is
/// <summary>
/// Maps unchanged parts to their previous input positions and looks up previous pair results.
/// A part is unchanged when its captured source object and exact pose bits match; identical
/// duplicates are matched in input order, which is safe because their inputs are bit-identical.
/// </summary>
private sealed class PairReuse
{
private readonly Dictionary<int, int> previousIds;
private readonly Dictionary<(int, int), PlateOverlapPair> pairs = new();
private readonly Dictionary<(int, int), PlateOverlapIssue> issues = new();
private PairReuse(Dictionary<int, int> previousIds, PlateOverlapReport previous)
{
this.previousIds = previousIds;
foreach (var pair in previous.Pairs)
pairs[(pair.PartAId, pair.PartBId)] = pair;
foreach (var issue in previous.Issues)
if (issue.PartBId.HasValue)
issues[(issue.PartAId, issue.PartBId.Value)] = issue;
}
public static PairReuse Create(PlateOverlapReport previous, PlateOverlapSnapshot snapshot)
{
if (previous?.Snapshot == null)
return null;
var available = new Dictionary<PoseKey, Queue<int>>();
foreach (var part in previous.Snapshot.Parts)
{
var key = PoseKey.Of(part);
if (!available.TryGetValue(key, out var ids))
available.Add(key, ids = new Queue<int>());
ids.Enqueue(part.Id);
}
var previousIds = new Dictionary<int, int>();
foreach (var part in snapshot.Parts)
if (available.TryGetValue(PoseKey.Of(part), out var ids) && ids.Count > 0)
previousIds.Add(part.Id, ids.Dequeue());
return previousIds.Count < 2 ? null : new PairReuse(previousIds, previous);
}
/// <summary>
/// Every bounding-box candidate pair among prepared parts was evaluated by the previous
/// analysis, and unchanged parts have identical bounds, so absence there means clear.
/// The previous pair must have had the same operand order: clipping is order-sensitive.
/// </summary>
public bool TryReuse(CapturedOverlapPart a, CapturedOverlapPart b,
List<PlateOverlapPair> pairOutput, List<PlateOverlapIssue> issueOutput)
{
if (!previousIds.TryGetValue(a.Id, out var oldA) || !previousIds.TryGetValue(b.Id, out var oldB)
|| oldA >= oldB)
return false;
if (pairs.TryGetValue((oldA, oldB), out var pair))
pairOutput.Add(pair.Renumber(a.Id, b.Id, a.Name, b.Name));
else if (issues.TryGetValue((oldA, oldB), out var issue))
issueOutput.Add(issue with { PartAId = a.Id, PartBId = b.Id });
return true;
}
}
private readonly record struct PoseKey(OverlapSource Source, long Rotation, long X, long Y)
{
public static PoseKey Of(CapturedOverlapPart part) => new(part.Source,
BitConverter.DoubleToInt64Bits(part.Rotation),
BitConverter.DoubleToInt64Bits(part.Location.X),
BitConverter.DoubleToInt64Bits(part.Location.Y));
}
internal static bool IsGeometryFailure(Exception exception) => exception is
ArgumentException or InvalidOperationException or NotSupportedException or ArithmeticException;
private static void ValidateProgram(Program program, HashSet<Program> visiting)
@@ -207,7 +288,5 @@ public static class PlateOverlapAnalyzer
visiting.Remove(program);
}
private sealed record CapturedSource(List<Entity> Entities, string Error);
private sealed record PreparedSource(OverlapMaterial Material, string Error);
private sealed record PreparedPart(CapturedOverlapPart Input, OverlapMaterial Material);
}
@@ -7,12 +7,17 @@ namespace OpenNest.Diagnostics;
/// <summary>An owned diagnostic result. An empty Pairs list is clear only if IsComplete is true.</summary>
public sealed class PlateOverlapReport
{
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues)
internal PlateOverlapReport(List<PlateOverlapPair> pairs, List<PlateOverlapIssue> issues,
PlateOverlapSnapshot snapshot)
{
Pairs = pairs.AsReadOnly();
Issues = issues.AsReadOnly();
Snapshot = snapshot;
}
/// <summary>The owned input this report was computed from; the baseline for incremental rechecks.</summary>
internal PlateOverlapSnapshot Snapshot { get; }
public IReadOnlyList<PlateOverlapPair> Pairs { get; }
public IReadOnlyList<PlateOverlapIssue> Issues { get; }
public bool IsComplete => Issues.Count == 0;
@@ -24,6 +29,23 @@ public sealed class PlateOverlapPair
{
private readonly Box bounds;
private PlateOverlapPair(PlateOverlapPair source, int partAId, int partBId, string partAName,
string partBName)
{
PartAId = partAId;
PartBId = partBId;
PartAName = partAName;
PartBName = partBName;
Regions = source.Regions;
Area = source.Area;
Centroid = source.Centroid;
bounds = source.bounds;
}
/// <summary>The same immutable geometry under new input positions and captured names.</summary>
internal PlateOverlapPair Renumber(int partAId, int partBId, string partAName, string partBName) =>
new(this, partAId, partBId, partAName, partBName);
internal PlateOverlapPair(int partAId, int partBId, string partAName, string partBName,
List<PlateOverlapRegion> regions, Vector centroid)
{
@@ -90,5 +112,5 @@ public sealed class PlateOverlapSnapshot
internal IReadOnlyList<PlateOverlapIssue> Issues { get; }
}
internal sealed record CapturedOverlapPart(int Id, string Name, List<Entity> Entities,
internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source,
double Rotation, Vector Location);
@@ -0,0 +1,167 @@
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;
}
}