Merge branch 'perf/fill-pipeline'
This commit is contained in:
@@ -234,6 +234,7 @@ namespace OpenNest
|
||||
|
||||
public bool Intersects(Part part, out List<Vector> pts)
|
||||
{
|
||||
PerfCounters.CountPartIntersects();
|
||||
pts = new List<Vector>();
|
||||
|
||||
var entities1 = ConvertProgram
|
||||
|
||||
@@ -53,6 +53,7 @@ namespace OpenNest
|
||||
/// </summary>
|
||||
public static List<Entity> GetOffsetPerimeterEntities(Part part, double spacing)
|
||||
{
|
||||
PerfCounters.CountOffsetPerimeterEntities();
|
||||
var geoEntities = ConvertProgram.ToGeometry(part.Program);
|
||||
var profile = new ShapeProfile(
|
||||
geoEntities.Where(e => e.Layer != SpecialLayers.Rapid).ToList()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest
|
||||
{
|
||||
/// <summary>
|
||||
/// Debug-only call counters for the fill hot paths. They confirm that a cache or shortcut
|
||||
/// actually removes the work it claims to; Release builds compile the increments away.
|
||||
/// </summary>
|
||||
public static class PerfCounters
|
||||
{
|
||||
private static long findBestFits;
|
||||
private static long offsetPerimeterEntities;
|
||||
private static long partIntersects;
|
||||
|
||||
public static long FindBestFits => Interlocked.Read(ref findBestFits);
|
||||
public static long OffsetPerimeterEntities => Interlocked.Read(ref offsetPerimeterEntities);
|
||||
public static long PartIntersects => Interlocked.Read(ref partIntersects);
|
||||
|
||||
[Conditional("DEBUG")]
|
||||
public static void CountFindBestFits() => Interlocked.Increment(ref findBestFits);
|
||||
|
||||
[Conditional("DEBUG")]
|
||||
public static void CountOffsetPerimeterEntities() =>
|
||||
Interlocked.Increment(ref offsetPerimeterEntities);
|
||||
|
||||
[Conditional("DEBUG")]
|
||||
public static void CountPartIntersects() => Interlocked.Increment(ref partIntersects);
|
||||
|
||||
public static void Reset()
|
||||
{
|
||||
Interlocked.Exchange(ref findBestFits, 0);
|
||||
Interlocked.Exchange(ref offsetPerimeterEntities, 0);
|
||||
Interlocked.Exchange(ref partIntersects, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Engine.Jobs.Placement;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Shapes;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// The candidate validator flattens each placed contour once and reuses it across pairs. These
|
||||
/// tests compare its accept/reject decision with a reference that rebuilds every contour and
|
||||
/// checks every edge pair, on arc-heavy parts placed right around the spacing.
|
||||
/// </summary>
|
||||
public class NestJobSpacingValidationTests
|
||||
{
|
||||
private const double Spacing = 0.25;
|
||||
private const double Epsilon = 0.0000001;
|
||||
private const double Origin = 50;
|
||||
|
||||
public static IEnumerable<object[]> Shapes() =>
|
||||
new[]
|
||||
{
|
||||
new object[] { "rounded", new RoundedRectangleShape { Length = 12, Width = 6, Radius = 1.5 }.GetDrawing().Program },
|
||||
new object[] { "ring", new RingShape { OuterDiameter = 8, InnerDiameter = 3 }.GetDrawing().Program },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Shapes))]
|
||||
public void SpacingDecisionMatchesBruteForceNearTheLimit(string name, OpenNest.CNC.Program program)
|
||||
{
|
||||
var geometry = PartGeometrySnapshot.FromProgram(program);
|
||||
var part = new NestJobPart("part", geometry, 2);
|
||||
var job = new NestJob(
|
||||
new[] { part },
|
||||
new[] { new NestPlateStock("stock", new Size(200, 200), 1, Spacing) }
|
||||
);
|
||||
|
||||
var bounds = program.BoundingBox();
|
||||
var random = new Random(name.GetHashCode(StringComparison.Ordinal) & 0x7fff);
|
||||
var accepted = 0;
|
||||
var rejected = 0;
|
||||
|
||||
for (var sample = 0; sample < 60; sample++)
|
||||
{
|
||||
// Second part beside or above the first with a gap near the spacing, shifted
|
||||
// sideways so corners and arcs meet at varied angles, and sometimes turned.
|
||||
var gap = Spacing + (random.NextDouble() - 0.5) * 0.06;
|
||||
var rotation = sample % 3 == 0 ? System.Math.PI : sample % 3 == 1 ? 0.0 : 0.05;
|
||||
var shift = (random.NextDouble() - 0.5) * 0.5 * bounds.Width;
|
||||
var beside = sample % 2 == 0;
|
||||
var second = beside
|
||||
? new NestJobPlacement("part", 1, Origin + bounds.Length + gap, Origin + shift, rotation)
|
||||
: new NestJobPlacement("part", 1, Origin + shift, Origin + bounds.Width + gap, rotation);
|
||||
var first = new NestJobPlacement("part", 0, Origin, Origin, 0);
|
||||
|
||||
var expectedValid = !ReferenceViolates(geometry, first, second);
|
||||
var actualValid = IsValid(job, first, second);
|
||||
|
||||
Assert.True(
|
||||
expectedValid == actualValid,
|
||||
$"{name} sample {sample}: gap {gap:F5}, shift {shift:F4}, rotation {rotation}: "
|
||||
+ $"brute force {(expectedValid ? "accepts" : "rejects")}, validator {(actualValid ? "accepts" : "rejects")}"
|
||||
);
|
||||
|
||||
if (actualValid)
|
||||
accepted++;
|
||||
else
|
||||
rejected++;
|
||||
}
|
||||
|
||||
// The sampling has to exercise both outcomes to mean anything.
|
||||
Assert.True(accepted > 5, $"only {accepted} accepted");
|
||||
Assert.True(rejected > 5, $"only {rejected} rejected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RingsPlacedExactlyAtSpacingPass()
|
||||
{
|
||||
var program = new RingShape { OuterDiameter = 8, InnerDiameter = 3 }.GetDrawing().Program;
|
||||
var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(program), 2);
|
||||
var job = new NestJob(
|
||||
new[] { part },
|
||||
new[] { new NestPlateStock("stock", new Size(200, 200), 1, Spacing) }
|
||||
);
|
||||
|
||||
// Inscribed chords never bring arcs closer, so an exact-spacing layout stays valid.
|
||||
Assert.True(IsValid(
|
||||
job,
|
||||
new NestJobPlacement("part", 0, Origin, Origin, 0),
|
||||
new NestJobPlacement("part", 1, Origin + 8 + Spacing, Origin, 0)
|
||||
));
|
||||
Assert.False(IsValid(
|
||||
job,
|
||||
new NestJobPlacement("part", 0, Origin, Origin, 0),
|
||||
new NestJobPlacement("part", 1, Origin + 8 + Spacing - 0.01, Origin, 0)
|
||||
));
|
||||
}
|
||||
|
||||
private static bool IsValid(NestJob job, params NestJobPlacement[] placements)
|
||||
{
|
||||
try
|
||||
{
|
||||
new NestJobRunner(_ => new CandidateNester(placements)).Solve(job);
|
||||
return true;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ReferenceViolates(
|
||||
PartGeometrySnapshot geometry,
|
||||
NestJobPlacement first,
|
||||
NestJobPlacement second
|
||||
)
|
||||
{
|
||||
var a = Contours(geometry, first);
|
||||
var b = Contours(geometry, second);
|
||||
|
||||
if (Collision.HasOverlap(a[0], b[0], a.Skip(1).ToList(), b.Skip(1).ToList()))
|
||||
return true;
|
||||
|
||||
var limit = Spacing - Epsilon;
|
||||
foreach (var left in a)
|
||||
{
|
||||
var leftLines = left.ToLines();
|
||||
foreach (var right in b)
|
||||
{
|
||||
var rightLines = right.ToLines();
|
||||
foreach (var l in leftLines)
|
||||
foreach (var r in rightLines)
|
||||
{
|
||||
if (l.Intersects(r))
|
||||
return true;
|
||||
var d = System.Math.Min(
|
||||
System.Math.Min(
|
||||
l.ClosestPointTo(r.StartPoint).DistanceTo(r.StartPoint),
|
||||
l.ClosestPointTo(r.EndPoint).DistanceTo(r.EndPoint)
|
||||
),
|
||||
System.Math.Min(
|
||||
r.ClosestPointTo(l.StartPoint).DistanceTo(l.StartPoint),
|
||||
r.ClosestPointTo(l.EndPoint).DistanceTo(l.EndPoint)
|
||||
)
|
||||
);
|
||||
if (d < limit)
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Perimeter polygon first, then cutouts, placed the way the validator places them.</summary>
|
||||
private static List<Polygon> Contours(PartGeometrySnapshot geometry, NestJobPlacement placement)
|
||||
{
|
||||
var entities = ConvertProgram
|
||||
.ToGeometry(DrawingJobMapper.ToProgram(geometry))
|
||||
.Where(e => !ReferenceEquals(e.Layer, SpecialLayers.Rapid))
|
||||
.ToList();
|
||||
var profile = new ShapeProfile(entities);
|
||||
profile.NormalizeWinding();
|
||||
|
||||
return new[] { profile.Perimeter }
|
||||
.Concat(profile.Cutouts)
|
||||
.Select(shape =>
|
||||
{
|
||||
var contour = (Shape)shape.Clone();
|
||||
contour.Rotate(placement.Rotation);
|
||||
contour.Offset(placement.X, placement.Y);
|
||||
return contour.ToPolygonWithTolerance(0.001);
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private sealed class CandidateNester(IEnumerable<NestJobPlacement> placements) : IPlateNester
|
||||
{
|
||||
public PlateCandidate Place(
|
||||
PlatePlacementRequest request,
|
||||
IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default
|
||||
) => new(placements);
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,23 @@ using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest.Engine.BestFit
|
||||
{
|
||||
/// <summary>
|
||||
/// Best-fit pair results per drawing. Entries are keyed weakly by the source drawing (canonical
|
||||
/// copies resolve through <see cref="CanonicalFrame.SourceOf"/>), so every fill of one drawing
|
||||
/// shares them and closed nests or finished solves release theirs. Candidates are computed once
|
||||
/// per spacing and filtered per plate size. An entry is dropped when the drawing's
|
||||
/// <see cref="Drawing.Program"/> instance or canonical angle changes.
|
||||
/// </summary>
|
||||
public static class BestFitCache
|
||||
{
|
||||
private const double StepSize = 0.25;
|
||||
|
||||
private static readonly ConcurrentDictionary<CacheKey, List<BestFitResult>> _cache =
|
||||
new ConcurrentDictionary<CacheKey, List<BestFitResult>>();
|
||||
private static readonly ConditionalWeakTable<Drawing, Entry> _entries = new();
|
||||
private static readonly object _entriesLock = new();
|
||||
|
||||
public static Func<Drawing, double, IPairEvaluator> CreateEvaluator { get; set; }
|
||||
public static Func<ISlideComputer> CreateSlideComputer { get; set; }
|
||||
@@ -22,11 +30,111 @@ namespace OpenNest.Engine.BestFit
|
||||
double spacing
|
||||
)
|
||||
{
|
||||
var key = new CacheKey(drawing, plateWidth, plateHeight, spacing);
|
||||
var entry = GetEntry(drawing);
|
||||
var key = (plateWidth, plateHeight, spacing);
|
||||
|
||||
if (_cache.TryGetValue(key, out var cached))
|
||||
if (entry.Filtered.TryGetValue(key, out var cached))
|
||||
return cached;
|
||||
|
||||
var candidates = GetCandidates(entry, drawing, spacing);
|
||||
return entry.Filtered.GetOrAdd(key, _ => FilterForSize(candidates, plateWidth, plateHeight));
|
||||
}
|
||||
|
||||
public static void ComputeForSizes(
|
||||
Drawing drawing,
|
||||
double spacing,
|
||||
IEnumerable<(double Width, double Height)> plateSizes
|
||||
)
|
||||
{
|
||||
foreach (var size in plateSizes)
|
||||
GetOrCompute(drawing, size.Width, size.Height, spacing);
|
||||
}
|
||||
|
||||
public static void Invalidate(Drawing drawing)
|
||||
{
|
||||
lock (_entriesLock)
|
||||
_entries.Remove(CanonicalFrame.SourceOf(drawing));
|
||||
}
|
||||
|
||||
public static void Populate(
|
||||
Drawing drawing,
|
||||
double plateWidth,
|
||||
double plateHeight,
|
||||
double spacing,
|
||||
List<BestFitResult> results
|
||||
)
|
||||
{
|
||||
if (results == null || results.Count == 0)
|
||||
return;
|
||||
|
||||
GetEntry(drawing).Filtered.TryAdd((plateWidth, plateHeight, spacing), results);
|
||||
}
|
||||
|
||||
public static Dictionary<
|
||||
(double PlateWidth, double PlateHeight, double Spacing),
|
||||
List<BestFitResult>
|
||||
> GetAllForDrawing(Drawing drawing)
|
||||
{
|
||||
var result = new Dictionary<(double, double, double), List<BestFitResult>>();
|
||||
var source = CanonicalFrame.SourceOf(drawing);
|
||||
|
||||
if (_entries.TryGetValue(source, out var entry) && entry.IsCurrentFor(source))
|
||||
{
|
||||
foreach (var kvp in entry.Filtered)
|
||||
result[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
lock (_entriesLock)
|
||||
_entries.Clear();
|
||||
}
|
||||
|
||||
private static Entry GetEntry(Drawing drawing)
|
||||
{
|
||||
var source = CanonicalFrame.SourceOf(drawing);
|
||||
|
||||
if (_entries.TryGetValue(source, out var entry) && entry.IsCurrentFor(source))
|
||||
return entry;
|
||||
|
||||
lock (_entriesLock)
|
||||
{
|
||||
if (_entries.TryGetValue(source, out entry) && entry.IsCurrentFor(source))
|
||||
return entry;
|
||||
|
||||
entry = new Entry(source);
|
||||
_entries.AddOrUpdate(source, entry);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<BestFitResult> GetCandidates(Entry entry, Drawing drawing, double spacing)
|
||||
{
|
||||
var lazy = entry.Unfiltered.GetOrAdd(
|
||||
spacing,
|
||||
_ => new Lazy<List<BestFitResult>>(
|
||||
() => ComputeCandidates(drawing, spacing),
|
||||
LazyThreadSafetyMode.ExecutionAndPublication
|
||||
)
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
return lazy.Value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Don't cache the failure; the next caller retries.
|
||||
entry.Unfiltered.TryRemove(new KeyValuePair<double, Lazy<List<BestFitResult>>>(spacing, lazy));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<BestFitResult> ComputeCandidates(Drawing drawing, double spacing)
|
||||
{
|
||||
// Operate on the canonical frame so cached pair positions are orientation-invariant.
|
||||
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||
|
||||
@@ -57,11 +165,9 @@ namespace OpenNest.Engine.BestFit
|
||||
}
|
||||
}
|
||||
|
||||
var finder = new BestFitFinder(plateWidth, plateHeight, evaluator, slideComputer);
|
||||
var results = finder.FindBestFits(canonical, spacing, StepSize);
|
||||
|
||||
_cache.TryAdd(key, results);
|
||||
return results;
|
||||
// The plate size only feeds the filter, which FindCandidates skips.
|
||||
var finder = new BestFitFinder(0, 0, evaluator, slideComputer);
|
||||
return finder.FindCandidates(canonical, spacing, StepSize);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -70,191 +176,57 @@ namespace OpenNest.Engine.BestFit
|
||||
}
|
||||
}
|
||||
|
||||
public static void ComputeForSizes(
|
||||
Drawing drawing,
|
||||
double spacing,
|
||||
IEnumerable<(double Width, double Height)> plateSizes
|
||||
)
|
||||
{
|
||||
// Skip sizes that are already cached.
|
||||
var needed = new List<(double Width, double Height)>();
|
||||
foreach (var size in plateSizes)
|
||||
{
|
||||
var key = new CacheKey(drawing, size.Width, size.Height, spacing);
|
||||
if (!_cache.ContainsKey(key))
|
||||
needed.Add(size);
|
||||
}
|
||||
|
||||
if (needed.Count == 0)
|
||||
return;
|
||||
|
||||
// Find the largest plate to use for the initial computation — this
|
||||
// keeps the filter maximally permissive so we don't discard results
|
||||
// that a smaller plate might still use after re-filtering.
|
||||
var maxWidth = 0.0;
|
||||
var maxHeight = 0.0;
|
||||
foreach (var size in needed)
|
||||
{
|
||||
if (size.Width > maxWidth)
|
||||
maxWidth = size.Width;
|
||||
if (size.Height > maxHeight)
|
||||
maxHeight = size.Height;
|
||||
}
|
||||
|
||||
IPairEvaluator evaluator = null;
|
||||
ISlideComputer slideComputer = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Operate on the canonical frame so cached pair positions are orientation-invariant.
|
||||
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||
|
||||
if (CreateEvaluator != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
evaluator = CreateEvaluator(canonical, spacing);
|
||||
}
|
||||
catch
|
||||
{ /* fall back to default evaluator */
|
||||
}
|
||||
}
|
||||
|
||||
if (CreateSlideComputer != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
slideComputer = CreateSlideComputer();
|
||||
}
|
||||
catch
|
||||
{ /* fall back to CPU slide computation */
|
||||
}
|
||||
}
|
||||
|
||||
// Compute candidates and evaluate once with the largest plate.
|
||||
var finder = new BestFitFinder(maxWidth, maxHeight, evaluator, slideComputer);
|
||||
var baseResults = finder.FindBestFits(canonical, spacing, StepSize);
|
||||
|
||||
// Cache a filtered copy for each plate size.
|
||||
foreach (var size in needed)
|
||||
{
|
||||
var filter = new BestFitFilter
|
||||
{
|
||||
MaxPlateWidth = size.Width,
|
||||
MaxPlateHeight = size.Height,
|
||||
};
|
||||
|
||||
var copy = new List<BestFitResult>(baseResults.Count);
|
||||
for (var i = 0; i < baseResults.Count; i++)
|
||||
{
|
||||
var r = baseResults[i];
|
||||
copy.Add(
|
||||
new BestFitResult
|
||||
{
|
||||
Candidate = r.Candidate,
|
||||
RotatedArea = r.RotatedArea,
|
||||
BoundingWidth = r.BoundingWidth,
|
||||
BoundingHeight = r.BoundingHeight,
|
||||
OptimalRotation = r.OptimalRotation,
|
||||
TrueArea = r.TrueArea,
|
||||
HullAngles = r.HullAngles,
|
||||
Keep = r.Keep,
|
||||
Reason = r.Reason,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
filter.Apply(copy);
|
||||
|
||||
var key = new CacheKey(drawing, size.Width, size.Height, spacing);
|
||||
_cache.TryAdd(key, copy);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
(evaluator as IDisposable)?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public static void Invalidate(Drawing drawing)
|
||||
{
|
||||
foreach (var key in _cache.Keys)
|
||||
{
|
||||
if (ReferenceEquals(key.Drawing, drawing))
|
||||
_cache.TryRemove(key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
public static void Populate(
|
||||
Drawing drawing,
|
||||
private static List<BestFitResult> FilterForSize(
|
||||
List<BestFitResult> candidates,
|
||||
double plateWidth,
|
||||
double plateHeight,
|
||||
double spacing,
|
||||
List<BestFitResult> results
|
||||
double plateHeight
|
||||
)
|
||||
{
|
||||
if (results == null || results.Count == 0)
|
||||
return;
|
||||
var copy = new List<BestFitResult>(candidates.Count);
|
||||
for (var i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
var r = candidates[i];
|
||||
copy.Add(
|
||||
new BestFitResult
|
||||
{
|
||||
Candidate = r.Candidate,
|
||||
RotatedArea = r.RotatedArea,
|
||||
BoundingWidth = r.BoundingWidth,
|
||||
BoundingHeight = r.BoundingHeight,
|
||||
OptimalRotation = r.OptimalRotation,
|
||||
TrueArea = r.TrueArea,
|
||||
HullAngles = r.HullAngles,
|
||||
Keep = r.Keep,
|
||||
Reason = r.Reason,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
var key = new CacheKey(drawing, plateWidth, plateHeight, spacing);
|
||||
_cache.TryAdd(key, results);
|
||||
BestFitFinder.CreateFilter(plateWidth, plateHeight).Apply(copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
public static Dictionary<
|
||||
(double PlateWidth, double PlateHeight, double Spacing),
|
||||
List<BestFitResult>
|
||||
> GetAllForDrawing(Drawing drawing)
|
||||
private sealed class Entry
|
||||
{
|
||||
var result = new Dictionary<(double, double, double), List<BestFitResult>>();
|
||||
foreach (var kvp in _cache)
|
||||
private readonly CNC.Program _program;
|
||||
private readonly double _sourceAngle;
|
||||
|
||||
public readonly ConcurrentDictionary<double, Lazy<List<BestFitResult>>> Unfiltered = new();
|
||||
|
||||
public readonly ConcurrentDictionary<
|
||||
(double PlateWidth, double PlateHeight, double Spacing),
|
||||
List<BestFitResult>
|
||||
> Filtered = new();
|
||||
|
||||
public Entry(Drawing source)
|
||||
{
|
||||
if (ReferenceEquals(kvp.Key.Drawing, drawing))
|
||||
result[(kvp.Key.PlateWidth, kvp.Key.PlateHeight, kvp.Key.Spacing)] = kvp.Value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
_cache.Clear();
|
||||
}
|
||||
|
||||
private readonly struct CacheKey : IEquatable<CacheKey>
|
||||
{
|
||||
public readonly Drawing Drawing;
|
||||
public readonly double PlateWidth;
|
||||
public readonly double PlateHeight;
|
||||
public readonly double Spacing;
|
||||
|
||||
public CacheKey(Drawing drawing, double plateWidth, double plateHeight, double spacing)
|
||||
{
|
||||
Drawing = drawing;
|
||||
PlateWidth = plateWidth;
|
||||
PlateHeight = plateHeight;
|
||||
Spacing = spacing;
|
||||
_program = source.Program;
|
||||
_sourceAngle = source.Source?.Angle ?? 0.0;
|
||||
}
|
||||
|
||||
public bool Equals(CacheKey other)
|
||||
{
|
||||
return ReferenceEquals(Drawing, other.Drawing)
|
||||
&& PlateWidth == other.PlateWidth
|
||||
&& PlateHeight == other.PlateHeight
|
||||
&& Spacing == other.Spacing;
|
||||
}
|
||||
|
||||
public override bool Equals(object obj) => obj is CacheKey other && Equals(other);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var hash = RuntimeHelpers.GetHashCode(Drawing);
|
||||
hash = hash * 397 ^ PlateWidth.GetHashCode();
|
||||
hash = hash * 397 ^ PlateHeight.GetHashCode();
|
||||
hash = hash * 397 ^ Spacing.GetHashCode();
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
public bool IsCurrentFor(Drawing source) =>
|
||||
ReferenceEquals(_program, source.Program)
|
||||
&& _sourceAngle == (source.Source?.Angle ?? 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,10 +28,19 @@ namespace OpenNest.Engine.BestFit
|
||||
slideComputer != null
|
||||
? (IDistanceComputer)new GpuDistanceComputer(slideComputer)
|
||||
: new CpuDistanceComputer();
|
||||
_filter = CreateFilter(maxPlateWidth, maxPlateHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The filter <see cref="FindBestFits"/> applies for a plate of the given size. The
|
||||
/// aspect-ratio limit widens with the plate's own aspect.
|
||||
/// </summary>
|
||||
public static BestFitFilter CreateFilter(double maxPlateWidth, double maxPlateHeight)
|
||||
{
|
||||
var plateAspect =
|
||||
System.Math.Max(maxPlateWidth, maxPlateHeight)
|
||||
/ System.Math.Max(System.Math.Min(maxPlateWidth, maxPlateHeight), 0.001);
|
||||
_filter = new BestFitFilter
|
||||
return new BestFitFilter
|
||||
{
|
||||
MaxPlateWidth = maxPlateWidth,
|
||||
MaxPlateHeight = maxPlateHeight,
|
||||
@@ -46,6 +55,39 @@ namespace OpenNest.Engine.BestFit
|
||||
BestFitSortField sortBy = BestFitSortField.Area
|
||||
)
|
||||
{
|
||||
var results = Evaluate(drawing, spacing, stepSize);
|
||||
|
||||
_filter.Apply(results);
|
||||
|
||||
return Number(SortResults(results, sortBy));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates every pair candidate without the plate-size filter, sorted by area. Applying
|
||||
/// <see cref="CreateFilter"/> for a plate size to shallow copies of these results gives
|
||||
/// the same results <see cref="FindBestFits"/> returns for that size, so one run can
|
||||
/// serve several sizes.
|
||||
/// </summary>
|
||||
public List<BestFitResult> FindCandidates(
|
||||
Drawing drawing,
|
||||
double spacing = 0.25,
|
||||
double stepSize = 0.25
|
||||
)
|
||||
{
|
||||
return Number(SortResults(Evaluate(drawing, spacing, stepSize), BestFitSortField.Area));
|
||||
}
|
||||
|
||||
private static List<BestFitResult> Number(List<BestFitResult> results)
|
||||
{
|
||||
for (var i = 0; i < results.Count; i++)
|
||||
results[i].Candidate.TestNumber = i;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private List<BestFitResult> Evaluate(Drawing drawing, double spacing, double stepSize)
|
||||
{
|
||||
PerfCounters.CountFindBestFits();
|
||||
var strategies = BuildStrategies(drawing, spacing);
|
||||
|
||||
var candidateBags = new ConcurrentBag<List<PairCandidate>>();
|
||||
@@ -64,16 +106,7 @@ namespace OpenNest.Engine.BestFit
|
||||
$"[BestFitFinder] {strategies.Count} strategies, {allCandidates.Count} candidates"
|
||||
);
|
||||
|
||||
var results = _evaluator.EvaluateAll(allCandidates);
|
||||
|
||||
_filter.Apply(results);
|
||||
|
||||
results = SortResults(results, sortBy);
|
||||
|
||||
for (var i = 0; i < results.Count; i++)
|
||||
results[i].Candidate.TestNumber = i;
|
||||
|
||||
return results;
|
||||
return _evaluator.EvaluateAll(allCandidates);
|
||||
}
|
||||
|
||||
public List<TileResult> FindAndTile(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.CompilerServices;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
@@ -11,6 +12,22 @@ namespace OpenNest.Engine
|
||||
/// </summary>
|
||||
public static class CanonicalFrame
|
||||
{
|
||||
// Maps each canonical copy to the drawing it was ultimately copied from, so caches keyed
|
||||
// by drawing identity hit across the transient copies each fill makes.
|
||||
private static readonly ConditionalWeakTable<Drawing, Drawing> sourceOf = new();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the drawing a canonical copy was made from (following copies of copies back to
|
||||
/// the root), or <paramref name="drawing"/> itself when it is not a canonical copy.
|
||||
/// </summary>
|
||||
internal static Drawing SourceOf(Drawing drawing)
|
||||
{
|
||||
if (drawing == null)
|
||||
return null;
|
||||
|
||||
return sourceOf.TryGetValue(drawing, out var root) ? root : drawing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new Drawing whose Program geometry is rotated to the canonical frame.
|
||||
/// The source drawing is not mutated.
|
||||
@@ -44,6 +61,7 @@ namespace OpenNest.Engine
|
||||
Angle = 0.0,
|
||||
},
|
||||
};
|
||||
sourceOf.AddOrUpdate(copy, SourceOf(drawing));
|
||||
return copy;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,16 @@ namespace OpenNest.Engine.Fill;
|
||||
/// Caches fill results by drawing and box dimensions so repeated fills
|
||||
/// of the same size don't recompute. Parts are stored normalized to origin
|
||||
/// and offset to the actual location on retrieval.
|
||||
///
|
||||
/// Entries are keyed weakly by the source drawing, so every canonical copy of one drawing shares
|
||||
/// them. Canonical and non-canonical callers are kept apart because cached parts are in the frame
|
||||
/// of the drawing they were computed for. An entry is dropped when the drawing's
|
||||
/// <see cref="Drawing.Program"/> instance or canonical angle changes.
|
||||
/// </summary>
|
||||
public static class FillResultCache
|
||||
{
|
||||
private static readonly ConcurrentDictionary<CacheKey, List<Part>> _cache = new();
|
||||
private static readonly ConditionalWeakTable<Drawing, Entry> _entries = new();
|
||||
private static readonly object _entriesLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Returns a cached fill result for the given drawing and box dimensions,
|
||||
@@ -20,9 +26,13 @@ public static class FillResultCache
|
||||
/// </summary>
|
||||
public static List<Part> Get(Drawing drawing, Box targetBox, double spacing)
|
||||
{
|
||||
var key = new CacheKey(drawing, targetBox.Width, targetBox.Length, spacing);
|
||||
var source = CanonicalFrame.SourceOf(drawing);
|
||||
if (!_entries.TryGetValue(source, out var entry) || !entry.IsCurrentFor(source))
|
||||
return null;
|
||||
|
||||
if (!_cache.TryGetValue(key, out var cached) || cached.Count == 0)
|
||||
var key = new CacheKey(drawing, source, targetBox.Width, targetBox.Length, spacing);
|
||||
|
||||
if (!entry.Results.TryGetValue(key, out var cached) || cached.Count == 0)
|
||||
return null;
|
||||
|
||||
var offset = targetBox.Location;
|
||||
@@ -42,9 +52,11 @@ public static class FillResultCache
|
||||
if (parts == null || parts.Count == 0)
|
||||
return;
|
||||
|
||||
var key = new CacheKey(drawing, sourceBox.Width, sourceBox.Length, spacing);
|
||||
var source = CanonicalFrame.SourceOf(drawing);
|
||||
var entry = GetEntry(source);
|
||||
var key = new CacheKey(drawing, source, sourceBox.Width, sourceBox.Length, spacing);
|
||||
|
||||
if (_cache.ContainsKey(key))
|
||||
if (entry.Results.ContainsKey(key))
|
||||
return;
|
||||
|
||||
var offset = new Vector(-sourceBox.X, -sourceBox.Y);
|
||||
@@ -53,46 +65,68 @@ public static class FillResultCache
|
||||
foreach (var part in parts)
|
||||
normalized.Add(part.CloneAtOffset(offset));
|
||||
|
||||
_cache.TryAdd(key, normalized);
|
||||
entry.Results.TryAdd(key, normalized);
|
||||
}
|
||||
|
||||
public static void Clear() => _cache.Clear();
|
||||
|
||||
public static int Count => _cache.Count;
|
||||
|
||||
private readonly struct CacheKey : System.IEquatable<CacheKey>
|
||||
public static void Clear()
|
||||
{
|
||||
public readonly Drawing Drawing;
|
||||
public readonly double Width;
|
||||
public readonly double Height;
|
||||
public readonly double Spacing;
|
||||
lock (_entriesLock)
|
||||
_entries.Clear();
|
||||
}
|
||||
|
||||
public CacheKey(Drawing drawing, double width, double height, double spacing)
|
||||
public static int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
Drawing = drawing;
|
||||
Width = System.Math.Round(width, 2);
|
||||
Height = System.Math.Round(height, 2);
|
||||
Spacing = spacing;
|
||||
var count = 0;
|
||||
foreach (var kvp in _entries)
|
||||
count += kvp.Value.Results.Count;
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
private static Entry GetEntry(Drawing source)
|
||||
{
|
||||
if (_entries.TryGetValue(source, out var entry) && entry.IsCurrentFor(source))
|
||||
return entry;
|
||||
|
||||
lock (_entriesLock)
|
||||
{
|
||||
if (_entries.TryGetValue(source, out entry) && entry.IsCurrentFor(source))
|
||||
return entry;
|
||||
|
||||
entry = new Entry(source);
|
||||
_entries.AddOrUpdate(source, entry);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Entry
|
||||
{
|
||||
private readonly CNC.Program program;
|
||||
private readonly double sourceAngle;
|
||||
|
||||
public readonly ConcurrentDictionary<CacheKey, List<Part>> Results = new();
|
||||
|
||||
public Entry(Drawing source)
|
||||
{
|
||||
program = source.Program;
|
||||
sourceAngle = source.Source?.Angle ?? 0.0;
|
||||
}
|
||||
|
||||
public bool Equals(CacheKey other) =>
|
||||
ReferenceEquals(Drawing, other.Drawing)
|
||||
&& Width == other.Width
|
||||
&& Height == other.Height
|
||||
&& Spacing == other.Spacing;
|
||||
public bool IsCurrentFor(Drawing source) =>
|
||||
ReferenceEquals(program, source.Program)
|
||||
&& sourceAngle == (source.Source?.Angle ?? 0.0);
|
||||
}
|
||||
|
||||
public override bool Equals(object obj) => obj is CacheKey other && Equals(other);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
var hash = RuntimeHelpers.GetHashCode(Drawing);
|
||||
hash = hash * 397 ^ Width.GetHashCode();
|
||||
hash = hash * 397 ^ Height.GetHashCode();
|
||||
hash = hash * 397 ^ Spacing.GetHashCode();
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
private readonly record struct CacheKey(bool IsCanonical, double Width, double Height, double Spacing)
|
||||
{
|
||||
public CacheKey(Drawing drawing, Drawing source, double width, double height, double spacing)
|
||||
: this(
|
||||
!ReferenceEquals(drawing, source),
|
||||
System.Math.Round(width, 2),
|
||||
System.Math.Round(height, 2),
|
||||
spacing
|
||||
) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ namespace OpenNest.Engine.Jobs;
|
||||
internal static class NestJobPlacementValidator
|
||||
{
|
||||
private const double Epsilon = 0.0000001;
|
||||
// Flattening for placement overlap/spacing checks: the same 0.001 the benchmark's
|
||||
// NestValidator and Part.Intersects use. Arcs are inscribed, so a layout placed exactly at
|
||||
// the spacing passes; outward arcs may come up to this much closer than the spacing.
|
||||
private const double PlacementChordTolerance = 0.001;
|
||||
|
||||
internal static void ValidateCandidate(
|
||||
PlateCandidate candidate,
|
||||
@@ -24,6 +28,7 @@ internal static class NestJobPlacementValidator
|
||||
throw new InvalidOperationException("The plate nester returned a null candidate.");
|
||||
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var placed = new List<ShapeTopology>();
|
||||
var sources = new Dictionary<string, ShapeTopology>(StringComparer.Ordinal);
|
||||
foreach (var placement in candidate.Placements)
|
||||
{
|
||||
if (
|
||||
@@ -48,7 +53,9 @@ internal static class NestJobPlacementValidator
|
||||
"Candidate rotation is not allowed for the requirement."
|
||||
);
|
||||
|
||||
var shape = Transform(CreateShape(part.Geometry), placement);
|
||||
if (!sources.TryGetValue(placement.PartId, out var source))
|
||||
sources[placement.PartId] = source = CreateShape(part.Geometry);
|
||||
var shape = Transform(source, placement);
|
||||
if (!FitsWorkArea(shape, stock))
|
||||
throw new InvalidOperationException(
|
||||
"Candidate placement falls outside the usable stock area."
|
||||
@@ -296,8 +303,8 @@ internal static class NestJobPlacementValidator
|
||||
|
||||
private static bool Overlaps(ShapeTopology left, ShapeTopology right)
|
||||
{
|
||||
var leftPoly = ToPolygon(left.Perimeter);
|
||||
var rightPoly = ToPolygon(right.Perimeter);
|
||||
var leftPoly = left.Contours[0].Polygon;
|
||||
var rightPoly = right.Contours[0].Polygon;
|
||||
if (!leftPoly.BoundingBox.Intersects(rightPoly.BoundingBox))
|
||||
return false;
|
||||
// True material overlap requires shared interior area, not boundary touching.
|
||||
@@ -308,8 +315,8 @@ internal static class NestJobPlacementValidator
|
||||
return Collision.HasOverlap(
|
||||
leftPoly,
|
||||
rightPoly,
|
||||
ToPolygons(left.Cutouts),
|
||||
ToPolygons(right.Cutouts)
|
||||
left.CutoutPolygons,
|
||||
right.CutoutPolygons
|
||||
);
|
||||
}
|
||||
|
||||
@@ -365,44 +372,22 @@ internal static class NestJobPlacementValidator
|
||||
private static double Distance(ShapeTopology left, ShapeTopology right)
|
||||
{
|
||||
var result = double.PositiveInfinity;
|
||||
foreach (var leftContour in AllContours(left))
|
||||
foreach (var rightContour in AllContours(right))
|
||||
if (BoundsDistance(leftContour.BoundingBox, rightContour.BoundingBox) < result)
|
||||
foreach (var leftContour in left.Contours)
|
||||
foreach (var rightContour in right.Contours)
|
||||
if (BoundsDistance(leftContour.Bounds, rightContour.Bounds) < result)
|
||||
result = System.Math.Min(
|
||||
result,
|
||||
BoundaryDistance(ToPolygon(leftContour), ToPolygon(rightContour))
|
||||
BoundaryDistance(leftContour.Lines, rightContour.Lines)
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IEnumerable<Shape> AllContours(ShapeTopology shape)
|
||||
{
|
||||
yield return shape.Perimeter;
|
||||
foreach (var cutout in shape.Cutouts)
|
||||
yield return cutout;
|
||||
}
|
||||
|
||||
private static List<Polygon> ToPolygons(List<Shape> contours)
|
||||
{
|
||||
var polygons = new List<Polygon>(contours.Count);
|
||||
foreach (var contour in contours)
|
||||
polygons.Add(ToPolygon(contour));
|
||||
return polygons;
|
||||
}
|
||||
|
||||
private static Polygon ToPolygon(Shape contour)
|
||||
{
|
||||
var polygon = contour.ToPolygon();
|
||||
polygon.UpdateBounds();
|
||||
return polygon;
|
||||
}
|
||||
|
||||
private static double BoundaryDistance(Polygon left, Polygon right)
|
||||
private static double BoundaryDistance(List<Line> left, List<Line> right)
|
||||
{
|
||||
var result = double.PositiveInfinity;
|
||||
foreach (var leftLine in left.ToLines())
|
||||
foreach (var leftLine in left)
|
||||
{
|
||||
foreach (var rightLine in right.ToLines())
|
||||
foreach (var rightLine in right)
|
||||
{
|
||||
if (leftLine.Intersects(rightLine))
|
||||
return 0;
|
||||
@@ -429,7 +414,56 @@ internal static class NestJobPlacementValidator
|
||||
|
||||
private sealed class ShapeTopology(Shape perimeter, List<Shape> cutouts)
|
||||
{
|
||||
private Contour[] contours;
|
||||
private List<Polygon> cutoutPolygons;
|
||||
|
||||
internal Shape Perimeter { get; } = perimeter;
|
||||
internal List<Shape> Cutouts { get; } = cutouts;
|
||||
|
||||
/// <summary>The perimeter first, then the cutouts, each flattened once on first use.</summary>
|
||||
internal Contour[] Contours
|
||||
{
|
||||
get
|
||||
{
|
||||
if (contours != null)
|
||||
return contours;
|
||||
var result = new Contour[Cutouts.Count + 1];
|
||||
result[0] = new Contour(Perimeter);
|
||||
for (var i = 0; i < Cutouts.Count; i++)
|
||||
result[i + 1] = new Contour(Cutouts[i]);
|
||||
return contours = result;
|
||||
}
|
||||
}
|
||||
|
||||
internal List<Polygon> CutoutPolygons
|
||||
{
|
||||
get
|
||||
{
|
||||
if (cutoutPolygons != null)
|
||||
return cutoutPolygons;
|
||||
var result = new List<Polygon>(Cutouts.Count);
|
||||
for (var i = 1; i < Contours.Length; i++)
|
||||
result.Add(Contours[i].Polygon);
|
||||
return cutoutPolygons = result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A contour flattened once, at <see cref="PlacementChordTolerance"/>, for the overlap and
|
||||
/// spacing checks against every other placement.
|
||||
/// </summary>
|
||||
private sealed class Contour
|
||||
{
|
||||
internal Contour(Shape shape)
|
||||
{
|
||||
Polygon = shape.ToPolygonWithTolerance(PlacementChordTolerance);
|
||||
Bounds = Polygon.BoundingBox;
|
||||
Lines = Polygon.ToLines();
|
||||
}
|
||||
|
||||
internal Box Bounds { get; }
|
||||
internal Polygon Polygon { get; }
|
||||
internal List<Line> Lines { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using OpenNest.Engine;
|
||||
using OpenNest.Engine.BestFit;
|
||||
using OpenNest.Engine.Fill;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.Math;
|
||||
using OpenNest.Shapes;
|
||||
|
||||
namespace OpenNest.Tests.BestFit;
|
||||
|
||||
// BestFitCache is process-wide static state and these tests swap its evaluator factory,
|
||||
// so they must not run alongside other tests.
|
||||
[CollectionDefinition(nameof(FillCacheCollection), DisableParallelization = true)]
|
||||
public class FillCacheCollection { }
|
||||
|
||||
[Collection(nameof(FillCacheCollection))]
|
||||
public class BestFitCacheTests
|
||||
{
|
||||
private const double Spacing = 0.25;
|
||||
|
||||
private static Drawing MakeRotatedTShape()
|
||||
{
|
||||
var drawing = new TShape { Width = 10, Height = 8 }.GetDrawing();
|
||||
drawing.Program.Rotate(Angle.ToRadians(30), drawing.Program.BoundingBox().Center);
|
||||
drawing.RecomputeCanonicalAngle();
|
||||
return drawing;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts best-fit computations for one source drawing by wrapping the evaluator factory.
|
||||
/// </summary>
|
||||
private sealed class EvaluatorSpy : IDisposable
|
||||
{
|
||||
private readonly Func<Drawing, double, IPairEvaluator> previous;
|
||||
private readonly WeakReference<Drawing> source;
|
||||
private int count;
|
||||
|
||||
public EvaluatorSpy(Drawing source)
|
||||
{
|
||||
this.source = new WeakReference<Drawing>(source);
|
||||
previous = BestFitCache.CreateEvaluator;
|
||||
BestFitCache.CreateEvaluator = (drawing, spacing) =>
|
||||
{
|
||||
if (this.source.TryGetTarget(out var target)
|
||||
&& ReferenceEquals(CanonicalFrame.SourceOf(drawing), target))
|
||||
Interlocked.Increment(ref count);
|
||||
return new PairEvaluator();
|
||||
};
|
||||
}
|
||||
|
||||
public int Count => Volatile.Read(ref count);
|
||||
|
||||
public void Dispose() => BestFitCache.CreateEvaluator = previous;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCompute_CanonicalCopiesOfOneDrawing_ShareOneComputation()
|
||||
{
|
||||
var drawing = MakeRotatedTShape();
|
||||
using var spy = new EvaluatorSpy(drawing);
|
||||
|
||||
var first = BestFitCache.GetOrCompute(CanonicalFrame.AsCanonicalCopy(drawing), 60, 40, Spacing);
|
||||
var copyOfCopy = CanonicalFrame.AsCanonicalCopy(CanonicalFrame.AsCanonicalCopy(drawing));
|
||||
var second = BestFitCache.GetOrCompute(copyOfCopy, 60, 40, Spacing);
|
||||
var third = BestFitCache.GetOrCompute(drawing, 60, 40, Spacing);
|
||||
|
||||
Assert.Same(first, second);
|
||||
Assert.Same(first, third);
|
||||
Assert.Equal(1, spy.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCompute_TwoPlateSizes_RunsFinderOnceAndMatchesPerSizeFinder()
|
||||
{
|
||||
var drawing = MakeRotatedTShape();
|
||||
using var spy = new EvaluatorSpy(drawing);
|
||||
|
||||
var sizes = new[] { (Width: 60.0, Height: 40.0), (Width: 14.0, Height: 30.0) };
|
||||
var cached = sizes
|
||||
.Select(s => BestFitCache.GetOrCompute(drawing, s.Width, s.Height, Spacing))
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(1, spy.Count);
|
||||
|
||||
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||
for (var i = 0; i < sizes.Length; i++)
|
||||
{
|
||||
var expected = new BestFitFinder(sizes[i].Width, sizes[i].Height)
|
||||
.FindBestFits(canonical, Spacing, 0.25);
|
||||
|
||||
Assert.Equal(Signature(expected), Signature(cached[i]));
|
||||
}
|
||||
|
||||
// The small plate must actually reject something the large one keeps.
|
||||
Assert.True(cached[1].Count(r => r.Keep) < cached[0].Count(r => r.Keep));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCompute_ReplacingProgram_InvalidatesEntry()
|
||||
{
|
||||
var drawing = MakeRotatedTShape();
|
||||
using var spy = new EvaluatorSpy(drawing);
|
||||
|
||||
var before = BestFitCache.GetOrCompute(drawing, 60, 40, Spacing);
|
||||
drawing.Program = (OpenNest.CNC.Program)drawing.Program.Clone();
|
||||
var after = BestFitCache.GetOrCompute(drawing, 60, 40, Spacing);
|
||||
|
||||
Assert.NotSame(before, after);
|
||||
Assert.Equal(2, spy.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetOrCompute_ReleasedDrawing_IsCollectable()
|
||||
{
|
||||
var weak = ComputeForTransientDrawing();
|
||||
|
||||
for (var i = 0; i < 3 && weak.IsAlive; i++)
|
||||
{
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
}
|
||||
|
||||
Assert.False(weak.IsAlive);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.NoInlining)]
|
||||
private static WeakReference ComputeForTransientDrawing()
|
||||
{
|
||||
var drawing = MakeRotatedTShape();
|
||||
var canonical = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||
BestFitCache.GetOrCompute(canonical, 60, 40, Spacing);
|
||||
FillResultCache.Store(canonical, new Box(0, 0, 60, 40), Spacing, new List<Part> { new Part(canonical) });
|
||||
return new WeakReference(drawing);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Populate_FeedsGetOrComputeAndRoundTripsThroughGetAllForDrawing()
|
||||
{
|
||||
var drawing = MakeRotatedTShape();
|
||||
using var spy = new EvaluatorSpy(drawing);
|
||||
var results = new List<BestFitResult>
|
||||
{
|
||||
new BestFitResult { Candidate = new PairCandidate { Drawing = drawing }, Keep = true },
|
||||
};
|
||||
|
||||
BestFitCache.Populate(drawing, 60, 40, Spacing, results);
|
||||
|
||||
Assert.Same(results, BestFitCache.GetOrCompute(CanonicalFrame.AsCanonicalCopy(drawing), 60, 40, Spacing));
|
||||
Assert.Equal(0, spy.Count);
|
||||
|
||||
var all = BestFitCache.GetAllForDrawing(drawing);
|
||||
Assert.Single(all);
|
||||
Assert.Same(results, all[(60, 40, Spacing)]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FillResultCache_HitThroughSecondCanonicalCopy_RebindsToSameParts()
|
||||
{
|
||||
var drawing = MakeRotatedTShape();
|
||||
var box = new Box(5, 7, 80, 50);
|
||||
|
||||
var firstCopy = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||
var computed = new FillLinear(box, Spacing).Fill(firstCopy, 0, NestDirection.Horizontal);
|
||||
Assert.NotEmpty(computed);
|
||||
FillResultCache.Store(firstCopy, box, Spacing, computed);
|
||||
|
||||
var secondCopy = CanonicalFrame.AsCanonicalCopy(drawing);
|
||||
var hit = FillResultCache.Get(secondCopy, box, Spacing);
|
||||
Assert.NotNull(hit);
|
||||
|
||||
// A non-canonical caller must not be served canonical-frame parts.
|
||||
Assert.Null(FillResultCache.Get(drawing, box, Spacing));
|
||||
|
||||
var expected = CanonicalFrame.RebindToOriginal(computed.Select(p => (Part)p.Clone()).ToList(), drawing);
|
||||
var actual = CanonicalFrame.RebindToOriginal(hit, drawing);
|
||||
|
||||
Assert.Equal(expected.Count, actual.Count);
|
||||
for (var i = 0; i < expected.Count; i++)
|
||||
{
|
||||
Assert.Same(drawing, actual[i].BaseDrawing);
|
||||
Assert.Equal(expected[i].Rotation, actual[i].Rotation, 9);
|
||||
Assert.Equal(expected[i].BoundingBox.X, actual[i].BoundingBox.X, 6);
|
||||
Assert.Equal(expected[i].BoundingBox.Y, actual[i].BoundingBox.Y, 6);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> Signature(List<BestFitResult> results) =>
|
||||
results
|
||||
.Select(r =>
|
||||
FormattableString.Invariant(
|
||||
$"{r.Candidate.Part2Rotation:F6}|{r.Candidate.Part2Offset.X:F6}|{r.Candidate.Part2Offset.Y:F6}|{r.RotatedArea:F6}|{r.Keep}|{r.Reason}"
|
||||
)
|
||||
)
|
||||
.OrderBy(s => s, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
Reference in New Issue
Block a user