Files
OpenNest/OpenNest.Engine.Tests/NestingEngines/IrregularPairPoolTests.cs
T
aj a89adc7911 fix(engine): stop waiting for the block fill's pair search on cancel
Irregular's block catalog fills a private plate per part type, and that
fill searched best-fit pairs through the overload that ignores the
token. On one CPU the search can take minutes, so a time limit waited
for it: the converted Nestbolt m-public-gear-1 job under a 60 s limit
ran past 180 s. PrivatePlateFill now passes its token, as PairCatalog
does since 9431006e; the search still finishes in the background and
fills the shared cache.

gear-1, Irregular, one CPU, 60 s limit: killed after 180 s -> stops at
60.0 s (no layout yet; its no-fit polygons are the next bottleneck).
2026-11-01 16:28:42 -05:00

349 lines
13 KiB
C#

using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Adapters;
using OpenNest.Engine.Jobs.Placement;
using OpenNest.Engine.NestingEngines.Irregular;
using static OpenNest.Engine.Tests.NestingEngines.JobBuilder;
using static OpenNest.Engine.Tests.NestingEngines.Shapes;
using Irregular = OpenNest.Engine.NestingEngines.Irregular;
namespace OpenNest.Engine.Tests.NestingEngines;
// The best-fit pool is process-wide state and these tests swap the evaluator factory and the
// pool capacity, so they run alone.
[CollectionDefinition(nameof(BestFitPoolCollection), DisableParallelization = true)]
public class BestFitPoolCollection { }
/// <summary>
/// Best-fit candidates for job geometry are pooled by content, so a repeated or re-tuned job of
/// the same parts (fresh snapshots, fresh engine instances) does not search again and places
/// the parts exactly as the first job did.
/// </summary>
[Collection(nameof(BestFitPoolCollection))]
public class IrregularPairPoolTests
{
private const double Spacing = 0.25;
/// <summary>Counts best-fit searches of one geometry by wrapping the evaluator factory.</summary>
private sealed class SearchSpy : IDisposable
{
private readonly Func<Drawing, double, IPairEvaluator>? previous;
private readonly PartGeometrySnapshot geometry;
private int count;
public SearchSpy(OpenNest.CNC.Program program)
{
geometry = PartGeometrySnapshot.FromProgram(program);
previous = BestFitCache.CreateEvaluator;
BestFitCache.CreateEvaluator = (drawing, _) =>
{
if (PartGeometrySnapshot.ContentComparer.Equals(
PartGeometrySnapshot.FromProgram(drawing.Program), geometry))
Interlocked.Increment(ref count);
return new PairEvaluator();
};
}
public int Count => Volatile.Read(ref count);
public void Dispose() => BestFitCache.CreateEvaluator = previous!;
}
private sealed class FailingEvaluator : IPairEvaluator
{
public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates) =>
throw new InvalidOperationException("search failed");
}
// Each test uses its own geometry so pooled sets from other tests cannot satisfy it.
private static OpenNest.CNC.Program Wedge(double top) => Polyline((0, 0), (8.3, 0), (top, 20.7), (0, 20.7));
private static (NestJob Job, IReadOnlyList<Irregular.PartType> Types) FreshJob(OpenNest.CNC.Program program, int quantity)
{
var job = Job(new[] { Part("wedge", program, quantity, RotationPolicy.Automatic) },
new[] { Stock("sheet", 17.3, 31.7, spacing: Spacing) });
return (job, PartCatalog.Build(job));
}
private static List<(double, double, double, double, double)> Poses(IReadOnlyDictionary<int, IReadOnlyList<PairPose>> pairs) =>
pairs.SelectMany(kv => kv.Value.Select(p => (p.A.Rotation, p.B.Rotation, p.Dx, p.Dy, (double)kv.Key))).ToList();
[Fact]
public void PairCatalog_SecondJobOfTheSameGeometry_SkipsTheSearchAndOffersTheSamePairs()
{
BestFitCache.Clear();
var program = Wedge(5.9);
using var spy = new SearchSpy(program);
var (first, firstTypes) = FreshJob(program, 2);
var firstPairs = PairCatalog.Build(firstTypes, Spacing, 31.7, 17.3, CancellationToken.None);
Assert.NotEmpty(firstPairs[0]);
Assert.Equal(1, spy.Count);
Assert.True(BestFitCache.IsShared(first.Parts[0].Geometry, Spacing));
var (second, secondTypes) = FreshJob(program, 2);
Assert.NotSame(first.Parts[0].Geometry, second.Parts[0].Geometry);
var secondPairs = PairCatalog.Build(secondTypes, Spacing, 31.7, 17.3, CancellationToken.None);
Assert.Equal(1, spy.Count);
Assert.Equal(Poses(firstPairs), Poses(secondPairs));
}
private sealed class BlockedEvaluator(ManualResetEventSlim release) : IPairEvaluator
{
public List<BestFitResult> EvaluateAll(List<PairCandidate> candidates)
{
release.Wait(TimeSpan.FromSeconds(30));
return new List<BestFitResult>();
}
}
[Fact]
public void PairCatalog_CancelingStopsTheWaitForASlowSearch()
{
// A time limit must not wait for the best-fit search, which can take minutes on one
// CPU; the search itself finishes in the background.
BestFitCache.Clear();
var (_, types) = FreshJob(Wedge(6.3), 2);
using var release = new ManualResetEventSlim();
var previous = BestFitCache.CreateEvaluator;
BestFitCache.CreateEvaluator = (_, _) => new BlockedEvaluator(release);
try
{
using var limit = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
var clock = System.Diagnostics.Stopwatch.StartNew();
Assert.ThrowsAny<OperationCanceledException>(() =>
PairCatalog.Build(types, Spacing, 31.7, 17.3, limit.Token));
Assert.True(
clock.Elapsed < TimeSpan.FromSeconds(5),
$"waited {clock.Elapsed.TotalSeconds:0.0} s"
);
}
finally
{
release.Set();
BestFitCache.CreateEvaluator = previous!;
BestFitCache.Clear();
}
}
[Fact]
public void PrivatePlateFill_CancelingStopsTheWaitForASlowSearch()
{
// Irregular's block catalog fills a private plate per type, which searches best-fit pairs
// too: a time limit must not wait for that search either.
BestFitCache.Clear();
var drawing = new Drawing("wedge", Wedge(6.3));
using var release = new ManualResetEventSlim();
var previous = BestFitCache.CreateEvaluator;
BestFitCache.CreateEvaluator = (_, _) => new BlockedEvaluator(release);
try
{
using var limit = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
var clock = System.Diagnostics.Stopwatch.StartNew();
Assert.ThrowsAny<OperationCanceledException>(() => PrivatePlateFill.Run(
drawing, RotationPolicy.Fixed(0), Spacing, 31.7, 17.3, 4, limit.Token));
Assert.True(
clock.Elapsed < TimeSpan.FromSeconds(5),
$"waited {clock.Elapsed.TotalSeconds:0.0} s"
);
}
finally
{
release.Set();
BestFitCache.CreateEvaluator = previous!;
BestFitCache.Clear();
}
}
[Fact]
public void IrregularEngine_FreshInstanceAndJob_PlacesIdenticallyWithoutSearchingAgain()
{
BestFitCache.Clear();
var program = Wedge(6.1);
using var spy = new SearchSpy(program);
var (first, _) = FreshJob(program, 4);
var firstResult = new IrregularNestingEngine().Solve(first);
Assert.Equal(NestJobStatus.Complete, firstResult.Status);
LayoutAssert.Valid(first, firstResult);
Assert.Equal(1, spy.Count);
var (second, _) = FreshJob(program, 4);
var secondResult = new IrregularNestingEngine().Solve(second);
Assert.Equal(1, spy.Count);
Assert.Equal(Placements(firstResult), Placements(secondResult));
}
[Fact]
public void PairCatalog_ReusesTheSearchOnlyWhenTheSpacingMatches()
{
BestFitCache.Clear();
var program = Wedge(6.3);
using var spy = new SearchSpy(program);
var (job, types) = FreshJob(program, 2);
PairCatalog.Build(types, Spacing, 31.7, 17.3, CancellationToken.None);
PairCatalog.Build(types, Spacing, 31.7, 17.3, CancellationToken.None);
Assert.Equal(1, spy.Count);
Assert.False(BestFitCache.IsShared(job.Parts[0].Geometry, 0.5));
PairCatalog.Build(types, 0.5, 31.7, 17.3, CancellationToken.None);
Assert.Equal(2, spy.Count);
Assert.True(BestFitCache.IsShared(job.Parts[0].Geometry, 0.5));
}
[Fact]
public void SharedCapacity_DropsTheLeastRecentlyUsedGeometryFirst()
{
BestFitCache.Clear();
var capacity = BestFitCache.SharedCapacity;
try
{
BestFitCache.SharedCapacity = 2;
var programs = new[] { Wedge(6.5), Wedge(6.7), Wedge(6.9) };
var parts = programs.Select(p => Part("w", p, 2, RotationPolicy.Automatic)).ToArray();
Compute(parts[0]);
Compute(parts[1]);
Compute(parts[0]); // The first becomes the most recently used.
Compute(parts[2]);
Assert.True(BestFitCache.IsShared(parts[0].Geometry, Spacing));
Assert.False(BestFitCache.IsShared(parts[1].Geometry, Spacing));
Assert.True(BestFitCache.IsShared(parts[2].Geometry, Spacing));
using var spy = new SearchSpy(programs[1]);
Compute(parts[1]);
Assert.Equal(1, spy.Count);
}
finally
{
BestFitCache.SharedCapacity = capacity;
}
}
[Fact]
public void SharedCapacityZero_DisablesPoolingWithoutBreakingTheSearch()
{
BestFitCache.Clear();
var capacity = BestFitCache.SharedCapacity;
try
{
BestFitCache.SharedCapacity = 0;
var program = Wedge(7.1);
var part = Part("w", program, 2, RotationPolicy.Automatic);
using var spy = new SearchSpy(program);
Assert.NotEmpty(Compute(part));
Assert.False(BestFitCache.IsShared(part.Geometry, Spacing));
Assert.NotEmpty(Compute(part));
Assert.Equal(2, spy.Count);
}
finally
{
BestFitCache.SharedCapacity = capacity;
}
}
[Fact]
public void Clear_DropsThePool()
{
BestFitCache.Clear();
var program = Wedge(7.3);
var part = Part("w", program, 2, RotationPolicy.Automatic);
using var spy = new SearchSpy(program);
Compute(part);
Assert.True(BestFitCache.IsShared(part.Geometry, Spacing));
BestFitCache.Clear();
Assert.False(BestFitCache.IsShared(part.Geometry, Spacing));
Compute(part);
Assert.Equal(2, spy.Count);
}
[Fact]
public void FailedSearch_IsNotPooled()
{
BestFitCache.Clear();
var program = Wedge(7.5);
var part = Part("w", program, 2, RotationPolicy.Automatic);
var previous = BestFitCache.CreateEvaluator;
try
{
BestFitCache.CreateEvaluator = (_, _) => new FailingEvaluator();
Assert.Throws<InvalidOperationException>(() => Compute(part));
Assert.False(BestFitCache.IsShared(part.Geometry, Spacing));
}
finally
{
BestFitCache.CreateEvaluator = previous!;
}
Assert.NotEmpty(Compute(part));
Assert.True(BestFitCache.IsShared(part.Geometry, Spacing));
}
[Fact]
public void ShareByGeometry_LapsesWhenTheProgramIsReplaced()
{
BestFitCache.Clear();
var program = Wedge(7.7);
var part = Part("w", program, 2, RotationPolicy.Automatic);
using var spy = new SearchSpy(program);
var drawing = DrawingJobMapper.CreateDrawing(part);
BestFitCache.GetOrCompute(drawing, 31.7, 17.3, Spacing);
Assert.Equal(1, spy.Count);
drawing.Program = (OpenNest.CNC.Program)drawing.Program.Clone();
BestFitCache.GetOrCompute(drawing, 31.7, 17.3, Spacing);
// The drawing no longer vouches for the geometry, so it searches on its own.
Assert.Equal(2, spy.Count);
}
[Fact]
public void ContentComparer_MatchesExactMotionsOnly()
{
var comparer = PartGeometrySnapshot.ContentComparer;
var a = PartGeometrySnapshot.FromProgram(Wedge(7.9));
var b = PartGeometrySnapshot.FromProgram(Wedge(7.9));
var c = PartGeometrySnapshot.FromProgram(Wedge(7.9 + 1e-12));
Assert.NotSame(a, b);
Assert.False(a.Equals(b));
Assert.True(comparer.Equals(a, b));
Assert.Equal(comparer.GetHashCode(a), comparer.GetHashCode(b));
Assert.False(comparer.Equals(a, c));
var zero = PartGeometrySnapshot.FromProgram(Polyline((0, 0), (5, 0), (5, 5)));
var negativeZero = PartGeometrySnapshot.FromProgram(Polyline((-0.0, 0), (5, 0), (5, 5)));
Assert.False(comparer.Equals(zero, negativeZero));
var suppressed = Wedge(7.9);
((OpenNest.CNC.Motion)suppressed.Codes[1]).Suppressed = true;
Assert.False(comparer.Equals(a, PartGeometrySnapshot.FromProgram(suppressed)));
}
private static List<BestFitResult> Compute(NestJobPart part)
{
var drawing = DrawingJobMapper.CreateDrawing(part);
try
{
return BestFitCache.GetOrCompute(drawing, 31.7, 17.3, Spacing);
}
finally
{
BestFitCache.Invalidate(drawing);
}
}
private static List<(string, double, double, double)> Placements(NestJobResult result) =>
result.Plates.SelectMany(p => p.Placements.Select(q => (q.PartId, q.X, q.Y, q.Rotation))).ToList();
}