mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 11:31:07 -04:00
Every Auto Nest run, and every Default-engine run, recomputed each part's best-fit pairs: job snapshots are fresh objects and BestFitCache keyed its entries on Drawing reference, so the Irregular engine paid PairCatalog.Build (about half of a 118-part solve) on every run of the same drawings. BestFitCache now keeps a process-wide, bounded (512 sets, least recently used first) pool of unfiltered candidate sets keyed by the exact content of a PartGeometrySnapshot (mode and every motion value, bit for bit; PartGeometrySnapshot.ContentComparer) and the spacing. Every drawing DrawingJobMapper.CreateDrawing reconstructs from a snapshot declares that geometry (BestFitCache.ShareByGeometry), so the Irregular pairs and blocks, the cutout lattice and the Fill/Default job paths all read and fill the pool. Candidates depend on the program geometry and spacing alone, so a pooled set is the set the search would produce: benchmark layouts, costs and placements are unchanged on the synthetic nests (all engines) and on the 67-drawing, 118-part job. Failed searches are not pooled, Invalidate leaves the pool alone, Clear empties it, and a drawing whose Program is replaced stops vouching for its geometry. Same 118-part job solved twice in one benchmark process (--parallel 1): the second solve now takes 13.1 s after a 33.0 s first; before, 35.6 s after 41.8 s. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
277 lines
10 KiB
C#
277 lines
10 KiB
C#
using OpenNest.Engine.BestFit;
|
|
using OpenNest.Engine.Jobs;
|
|
using OpenNest.Engine.Jobs.Adapters;
|
|
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));
|
|
}
|
|
|
|
[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();
|
|
}
|