diff --git a/OpenNest.Core/Diagnostics/OverlapMaterialCache.cs b/OpenNest.Core/Diagnostics/OverlapMaterialCache.cs new file mode 100644 index 0000000..6f23fd2 --- /dev/null +++ b/OpenNest.Core/Diagnostics/OverlapMaterialCache.cs @@ -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; + +/// +/// 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 reference and released with it. A changed code +/// count or program rotation is detected, but that is not a geometry hash: call +/// 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. +/// +public sealed class OverlapMaterialCache +{ + private readonly ConditionalWeakTable sources = new(); + + public void Clear() => sources.Clear(); + + internal OverlapSource Get(Program program, Func create) + { + if (sources.TryGetValue(program, out var source) && source.Matches(program)) + return source; + source = create(program); + sources.AddOrUpdate(program, source); + return source; + } +} + +/// Owned converted entities for one clean program, plus its lazily prepared material. +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 entities, string error) + { + codeCount = program.Codes.Count; + rotation = BitConverter.DoubleToInt64Bits(program.Rotation); + Entities = entities; + Error = error; + } + + /// Never mutated; preparation clones before chaining. + internal List Entities { get; } + internal string Error { get; } + + internal bool Matches(Program program) => + program.Codes.Count == codeCount && BitConverter.DoubleToInt64Bits(program.Rotation) == rotation; + + /// + /// 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. + /// + 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; + } + } +} + +/// Validated local-frame material, only ever read (transformed into new polygons). +internal sealed record PreparedMaterial(OverlapMaterial Material, string Error); diff --git a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs index 3692b69..410b6a2 100644 --- a/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs +++ b/OpenNest.Core/Diagnostics/PlateOverlapAnalyzer.cs @@ -21,13 +21,22 @@ public static class PlateOverlapAnalyzer /// Inputs must not change during capture. Later analysis never reads live domain objects. /// public static PlateOverlapSnapshot Capture(IReadOnlyList parts, + CancellationToken cancellationToken = default) => + Capture(parts, new OverlapMaterialCache(), cancellationToken); + + /// + /// As , but reuses converted + /// and prepared drawing material from across requests. Clear the + /// cache before any in-place clean-program edit (see ). + /// + public static PlateOverlapSnapshot Capture(IReadOnlyList parts, OverlapMaterialCache cache, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(parts); + ArgumentNullException.ThrowIfNull(cache); cancellationToken.ThrowIfCancellationRequested(); var captured = new List(); var issues = new List(); - var sources = new Dictionary(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(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(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); + } + } + /// Convenience synchronous capture and analysis of a group of parts. public static PlateOverlapReport Analyze(IReadOnlyList parts, CancellationToken cancellationToken = default) => @@ -85,31 +95,33 @@ public static class PlateOverlapAnalyzer /// Cancellation throws and publishes no partial report. Check IsComplete before claiming clear. /// public static PlateOverlapReport Analyze(PlateOverlapSnapshot snapshot, + CancellationToken cancellationToken = default) => + Analyze(snapshot, null, cancellationToken); + + /// + /// Incremental recheck: identical to a full analysis of , but a + /// pair whose two parts are unchanged since (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 ; otherwise every + /// pair is recomputed. Null performs a full analysis. + /// + 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(); var prepared = new List(); - var sources = new Dictionary, 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 + /// + /// 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. + /// + private sealed class PairReuse + { + private readonly Dictionary previousIds; + private readonly Dictionary<(int, int), PlateOverlapPair> pairs = new(); + private readonly Dictionary<(int, int), PlateOverlapIssue> issues = new(); + + private PairReuse(Dictionary 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>(); + 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()); + ids.Enqueue(part.Id); + } + var previousIds = new Dictionary(); + 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); + } + + /// + /// 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. + /// + public bool TryReuse(CapturedOverlapPart a, CapturedOverlapPart b, + List pairOutput, List 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 visiting) @@ -207,7 +288,5 @@ public static class PlateOverlapAnalyzer visiting.Remove(program); } - private sealed record CapturedSource(List Entities, string Error); - private sealed record PreparedSource(OverlapMaterial Material, string Error); private sealed record PreparedPart(CapturedOverlapPart Input, OverlapMaterial Material); } diff --git a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs index 8be7228..900483d 100644 --- a/OpenNest.Core/Diagnostics/PlateOverlapReport.cs +++ b/OpenNest.Core/Diagnostics/PlateOverlapReport.cs @@ -7,12 +7,17 @@ namespace OpenNest.Diagnostics; /// An owned diagnostic result. An empty Pairs list is clear only if IsComplete is true. public sealed class PlateOverlapReport { - internal PlateOverlapReport(List pairs, List issues) + internal PlateOverlapReport(List pairs, List issues, + PlateOverlapSnapshot snapshot) { Pairs = pairs.AsReadOnly(); Issues = issues.AsReadOnly(); + Snapshot = snapshot; } + /// The owned input this report was computed from; the baseline for incremental rechecks. + internal PlateOverlapSnapshot Snapshot { get; } + public IReadOnlyList Pairs { get; } public IReadOnlyList 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; + } + + /// The same immutable geometry under new input positions and captured names. + 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 regions, Vector centroid) { @@ -90,5 +112,5 @@ public sealed class PlateOverlapSnapshot internal IReadOnlyList Issues { get; } } -internal sealed record CapturedOverlapPart(int Id, string Name, List Entities, +internal sealed record CapturedOverlapPart(int Id, string Name, OverlapSource Source, double Rotation, Vector Location); diff --git a/OpenNest.Tests/Diagnostics/IncrementalOverlapAnalysisTests.cs b/OpenNest.Tests/Diagnostics/IncrementalOverlapAnalysisTests.cs new file mode 100644 index 0000000..0f29522 --- /dev/null +++ b/OpenNest.Tests/Diagnostics/IncrementalOverlapAnalysisTests.cs @@ -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 + { + 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 { 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 { 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(() => 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(), new List { 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; + } +}