perf(engine): use cached triangulations in the layout check
The benchmark validator and every engine test re-triangulated both parts for each nearby pair. NestLayoutCheck now uses TriangulatedRegion, with Collision.HasOverlap as the fallback when it cannot decide. Verdicts are unchanged (the frozen-validator equivalence tests still pass); validating 100 discs went from 1,254 ms to 94 ms. Co-Authored-By: Codex <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,7 @@ public static class NestLayoutCheck
|
|||||||
}
|
}
|
||||||
var inflated = spacing > Tolerance.Epsilon ? Outline(ap, al, spacing) : ar;
|
var inflated = spacing > Tolerance.Epsilon ? Outline(ap, al, spacing) : ar;
|
||||||
return !BoxesTouch(inflated.Perimeter.BoundingBox, br.Perimeter.BoundingBox)
|
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)
|
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
|
// 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 i's (shrunk) cutout, or part i's inflated outline
|
||||||
// inside part j's raw cutout.
|
// inside part j's raw cutout.
|
||||||
if (
|
if (Overlaps(inflated[i], raw[j]))
|
||||||
Collision.HasOverlap(
|
|
||||||
inflated[i].Perimeter,
|
|
||||||
raw[j].Perimeter,
|
|
||||||
inflated[i].Holes,
|
|
||||||
raw[j].Holes
|
|
||||||
)
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
result.Add(
|
result.Add(
|
||||||
$"'{DisplayName(parts[i], requirements)}' and '{DisplayName(parts[j], requirements)}' are closer than the required spacing ({spacing:F3})"
|
$"'{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 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
|
private sealed class PartOutline
|
||||||
{
|
{
|
||||||
public Polygon Perimeter { get; init; }
|
public Polygon Perimeter { get; init; }
|
||||||
public List<Polygon> Holes { get; init; }
|
public List<Polygon> Holes { get; init; }
|
||||||
|
|
||||||
|
private bool prepared;
|
||||||
|
private TriangulatedRegion triangles;
|
||||||
|
|
||||||
|
/// <summary>Prepared on the first candidate pair; null preparation is cached too.</summary>
|
||||||
|
public TriangulatedRegion Triangles
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (!prepared)
|
||||||
|
{
|
||||||
|
triangles = TriangulatedRegion.Build(Perimeter, Holes);
|
||||||
|
prepared = true;
|
||||||
|
}
|
||||||
|
return triangles;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user