From a98a49c00a5f60885a9daacc7be0407babf4cd9e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 27 Sep 2026 12:54:20 -0400 Subject: [PATCH] test(fill): add opt-in overlap-check micro benchmark Times Collision.HasOverlap over the box-overlapping neighbour pairs of two FillLinear grids, and the FillHelpers.HasOverlappingParts grid checks themselves. Opt-in via OPENNEST_RUN_FILL_PERF=1 (Category=FillPerformance). Uses only APIs that predate the overlap-check work, so the same file can be copied into a before tree for same-harness comparisons. --- .../Fill/OverlapCheckPerformanceTests.cs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 OpenNest.Tests/Fill/OverlapCheckPerformanceTests.cs diff --git a/OpenNest.Tests/Fill/OverlapCheckPerformanceTests.cs b/OpenNest.Tests/Fill/OverlapCheckPerformanceTests.cs new file mode 100644 index 0000000..78663f8 --- /dev/null +++ b/OpenNest.Tests/Fill/OverlapCheckPerformanceTests.cs @@ -0,0 +1,140 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using OpenNest.Converters; +using OpenNest.Engine; +using OpenNest.Engine.Fill; +using OpenNest.Engine.Strategies; +using OpenNest.Geometry; +using OpenNest.Tests.BestFit; +using Xunit.Abstractions; + +namespace OpenNest.Tests.Fill; + +/// +/// Opt-in measurements of the fill overlap checks. Uses only APIs that predate the overlap-check +/// work (82feb78), so the file can be byte-copied into a before tree for same-harness pairs. +/// +[Collection(nameof(FillCacheCollection))] +[Trait("Category", "FillPerformance")] +public class OverlapCheckPerformanceTests +{ + private readonly ITestOutputHelper output; + + public OverlapCheckPerformanceTests(ITestOutputHelper output) => this.output = output; + + [SkippableFact] + public void OverlapCheck_ReportsPolygonPairsAndGridChecks() + { + Skip.IfNot(Environment.GetEnvironmentVariable("OPENNEST_RUN_FILL_PERF") == "1", + "Set OPENNEST_RUN_FILL_PERF=1 to run opt-in fill microbenchmarks."); + var area = new Box(3.1, 5.3, 96, 48); + var filler = new FillLinear(area, 0.5); + var arcGrid = filler.Fill(FillExtentsTests.MakeFixture("arc"), 0.37, NestDirection.Horizontal); + var first = Part.CreateAtOrigin(FillExtentsTests.MakeFixture("concave"), 0); + var second = Part.CreateAtOrigin(FillExtentsTests.MakeFixture("concave"), System.Math.PI); + second.Offset(new Vector(first.Right + 0.5, first.Bottom)); + var pairGrid = filler.Fill(FillHelpers.BuildRotatedPattern(new List { first, second }, 0.37), + NestDirection.Horizontal); + Assert.False(FillHelpers.HasOverlappingParts(arcGrid)); + Assert.False(FillHelpers.HasOverlappingParts(pairGrid)); + var pairs = BoxOverlappingPairs(arcGrid).Concat(BoxOverlappingPairs(pairGrid)).ToArray(); + Assert.NotEmpty(pairs); + Assert.All(pairs, p => Assert.False(Collision.HasOverlap(p.A, p.B))); + + var modes = new Func[] + { + () => + { + var hits = 0L; + foreach (var (a, b) in pairs) + hits += Collision.HasOverlap(a, b) ? 1 : 0; + return hits; + }, + () => FillHelpers.HasOverlappingParts(arcGrid) ? 1 : 0, + () => FillHelpers.HasOverlappingParts(pairGrid) ? 1 : 0, + }; + var names = new[] { "polygon-pairs", "grid-single", "grid-pair" }; + var warmupCalls = 20; + var callsPerBatch = 50; + var repetitions = 7; +#if DEBUG + output.WriteLine("Configuration=Debug (diagnostic only; use Release for measurements)."); +#else + output.WriteLine("Configuration=Release."); +#endif + output.WriteLine($"Runtime={RuntimeInformation.FrameworkDescription}; OS={RuntimeInformation.OSDescription}; " + + $"architecture={RuntimeInformation.ProcessArchitecture}; processors={Environment.ProcessorCount}; " + + $"Stopwatch.Frequency={Stopwatch.Frequency} ticks/s."); + output.WriteLine( + $"overlap-check: area=(3.1,5.3,96,48), spacing=0.5, Horizontal. grid-single = FillLinear.Fill(arc fixture, 0.37) " + + $"({arcGrid.Count} parts, one shared Program); grid-pair = FillLinear.Fill(BuildRotatedPattern(concave 0/PI pair, 0.37)) " + + $"({pairGrid.Count} parts); polygon-pairs = Collision.HasOverlap over the {pairs.Length} box-overlapping neighbour " + + "pairs of both grids (timed per full sweep), each polygon prepared once up front with Part.Intersects' recipe " + + "(material entities, chord 0.001, world offset); Part.Intersects itself prepares per call. All verdicts clear. " + + $"warmup=2 x {warmupCalls} calls/mode; measured={repetitions} x {callsPerBatch}; mode order rotates per batch. " + + "Setup/assertions/output excluded. Synchronous current-thread allocation bytes. Not a timing gate or whole-job estimate."); + for (var batch = 0; batch < 2; batch++) + for (var slot = 0; slot < modes.Length; slot++) + Measure(modes[(batch + slot) % modes.Length], warmupCalls); + var samples = names.Select(_ => new (double Ms, long Bytes)[repetitions]).ToArray(); + for (var batch = 0; batch < repetitions; batch++) + { + for (var slot = 0; slot < modes.Length; slot++) + { + var mode = (batch + slot) % modes.Length; + samples[mode][batch] = Measure(modes[mode], callsPerBatch); + } + for (var mode = 0; mode < modes.Length; mode++) + { + var (ms, bytes) = samples[mode][batch]; + output.WriteLine(FormattableString.Invariant( + $"overlap-check mode={names[mode]} batch={batch + 1}: us/call={ms * 1000 / callsPerBatch:F3}; B/call={(double)bytes / callsPerBatch:F1}; ms={ms:F6}; bytes={bytes}.")); + } + } + for (var mode = 0; mode < modes.Length; mode++) + { + var times = samples[mode].Select(s => s.Ms * 1000 / callsPerBatch).OrderBy(x => x).ToArray(); + var bytes = samples[mode].Select(s => (double)s.Bytes / callsPerBatch).OrderBy(x => x).ToArray(); + output.WriteLine(FormattableString.Invariant( + $"overlap-check {names[mode]}: us/call min/median/max={times[0]:F3}/{times[repetitions / 2]:F3}/{times[^1]:F3}; B/call min/median/max={bytes[0]:F1}/{bytes[repetitions / 2]:F1}/{bytes[^1]:F1}.")); + } + } + + private static (double Ms, long Bytes) Measure(Func run, int calls) + { + var hits = 0L; + var allocated = GC.GetAllocatedBytesForCurrentThread(); + var start = Stopwatch.GetTimestamp(); + for (var i = 0; i < calls; i++) + hits += run(); + var elapsed = Stopwatch.GetTimestamp() - start; + allocated = GC.GetAllocatedBytesForCurrentThread() - allocated; + Assert.Equal(0, hits); + return (elapsed * 1000.0 / Stopwatch.Frequency, allocated); + } + + private static List<(Polygon A, Polygon B)> BoxOverlappingPairs(List parts) + { + var polygons = parts.Select(PreparedPolygon).ToArray(); + var pairs = new List<(Polygon, Polygon)>(); + for (var i = 0; i < parts.Count; i++) + for (var j = i + 1; j < parts.Count; j++) + { + var b1 = parts[i].BoundingBox; + var b2 = parts[j].BoundingBox; + var overlapX = System.Math.Min(b1.Right, b2.Right) - System.Math.Max(b1.Left, b2.Left); + var overlapY = System.Math.Min(b1.Top, b2.Top) - System.Math.Max(b1.Bottom, b2.Bottom); + if (overlapX > OpenNest.Math.Tolerance.Epsilon && overlapY > OpenNest.Math.Tolerance.Epsilon) + pairs.Add((polygons[i], polygons[j])); + } + return pairs; + } + + private static Polygon PreparedPolygon(Part part) + { + var entities = ConvertProgram.ToGeometry(part.Program).Where(e => SpecialLayers.IsMaterial(e.Layer)).ToList(); + var polygon = new ShapeProfile(entities).Perimeter.ToPolygonWithTolerance(0.001); + polygon.Offset(part.Location); + return polygon; + } +}