diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs index c3e2ddd..f02ff37 100644 --- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs +++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs @@ -54,7 +54,7 @@ public static class NestLayoutCheck } var inflated = spacing > Tolerance.Epsilon ? Outline(ap, al, spacing) : ar; return !BoxesTouch(inflated.Perimeter.BoundingBox, br.Perimeter.BoundingBox) - || !Collision.HasOverlap(inflated.Perimeter, br.Perimeter, inflated.Holes, br.Holes); + || !Overlaps(inflated, br); } private static ShapeProfile Transform(JobPartGeometry geometry, double rotation) @@ -322,14 +322,7 @@ public static class NestLayoutCheck // Inflating one side by the full spacing covers both cases: part j // inside part i's (shrunk) cutout, or part i's inflated outline // inside part j's raw cutout. - if ( - Collision.HasOverlap( - inflated[i].Perimeter, - raw[j].Perimeter, - inflated[i].Holes, - raw[j].Holes - ) - ) + if (Overlaps(inflated[i], raw[j])) { result.Add( $"'{DisplayName(parts[i], requirements)}' and '{DisplayName(parts[j], requirements)}' are closer than the required spacing ({spacing:F3})" @@ -365,10 +358,37 @@ public static class NestLayoutCheck private const double OutlineTolerance = NestTolerances.ValidationOutline; + private static bool Overlaps(PartOutline a, PartOutline b) + { + var at = a.Triangles; + var bt = b.Triangles; + // Cache the same world-space triangulation the reference would build. Keeping + // translations at zero also preserves its floating-point operation order. + return (at != null && bt != null ? at.Overlaps(bt, 0, 0, 0, 0) : null) + ?? Collision.HasOverlap(a.Perimeter, b.Perimeter, a.Holes, b.Holes); + } + private sealed class PartOutline { public Polygon Perimeter { get; init; } public List Holes { get; init; } + + private bool prepared; + private TriangulatedRegion triangles; + + /// Prepared on the first candidate pair; null preparation is cached too. + public TriangulatedRegion Triangles + { + get + { + if (!prepared) + { + triangles = TriangulatedRegion.Build(Perimeter, Holes); + prepared = true; + } + return triangles; + } + } } /// diff --git a/OpenNest.Tests/Benchmark/NestLayoutCheckTimingTests.cs b/OpenNest.Tests/Benchmark/NestLayoutCheckTimingTests.cs new file mode 100644 index 0000000..b3659f8 --- /dev/null +++ b/OpenNest.Tests/Benchmark/NestLayoutCheckTimingTests.cs @@ -0,0 +1,71 @@ +using System.Diagnostics; +using OpenNest.Benchmark; +using OpenNest.CNC; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Geometry; +using Xunit.Abstractions; + +namespace OpenNest.Tests.Benchmark; + +public class NestLayoutCheckTimingTests +{ + private readonly ITestOutputHelper output; + + public NestLayoutCheckTimingTests(ITestOutputHelper output) => this.output = output; + + [Fact] + public void ManyPartLayoutMatchesFrozenValidatorAndReportsTiming() + { + const int count = 100; + var program = new Program(); + program.MoveTo(1, 0); + program.ArcTo(-1, 0, 0, 0, RotationType.CCW); + program.ArcTo(1, 0, 0, 0, RotationType.CCW); + var part = new NestJobPart("disc", PartGeometrySnapshot.FromProgram(program), count); + var stock = new NestPlateStock("sheet", new Size(30, 30), partSpacing: 0.01); + var job = new NestJob(new[] { part }, new[] { stock }); + var builder = new NestJobResultBuilder(job); + // Dense bounding-box candidates exercise the pair gate; both overlapping and + // disjoint diagonal neighbours occur. Violations are deliberate and compared. + builder.AddSheet(stock, Enumerable.Range(0, count) + .Select(i => (part.Id, 2 + (i % 10) * 1.5, 2 + (i / 10) * 1.5, 0.0))); + var result = builder.Build(NestJobStopReason.Completed); + var materialized = NestResultMaterializer.Materialize(job, result); + var requirements = job.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id], p => (p.Id, p.Quantity)); + var runs = materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList(); + var expected = LegacyNestValidator.Validate(runs, requirements).Violations; + Assert.NotEmpty(expected); + Assert.Equal(expected, NestValidator.Validate(runs, requirements).Violations); + Assert.Equal(expected, NestLayoutCheck.Violations(job, result)); + + var legacyTimes = new double[5]; + var currentTimes = new double[5]; + for (var i = 0; i < legacyTimes.Length; i++) + { + // Alternate order after warmup to reduce systematic timing bias. + if (i % 2 == 0) + { + legacyTimes[i] = Measure(() => Assert.Equal(expected, LegacyNestValidator.Validate(runs, requirements).Violations)); + currentTimes[i] = Measure(() => Assert.Equal(expected, NestValidator.Validate(runs, requirements).Violations)); + } + else + { + currentTimes[i] = Measure(() => Assert.Equal(expected, NestValidator.Validate(runs, requirements).Violations)); + legacyTimes[i] = Measure(() => Assert.Equal(expected, LegacyNestValidator.Validate(runs, requirements).Violations)); + } + } + Array.Sort(legacyTimes); + Array.Sort(currentTimes); + output.WriteLine($"100 discs; {expected.Count} identical ordered violations; " + + $"median of 5: frozen={legacyTimes[2]:F3}ms current={currentTimes[2]:F3}ms; " + + $"ratio={legacyTimes[2] / currentTimes[2]:F2}x. Includes outline preparation; excludes materialization."); + } + + private static double Measure(Action action) + { + var watch = Stopwatch.StartNew(); + action(); + return watch.Elapsed.TotalMilliseconds; + } +}