diff --git a/OpenNest.Engine/NestEngineBase.cs b/OpenNest.Engine/NestEngineBase.cs index 29c8eb6..c8d81b1 100644 --- a/OpenNest.Engine/NestEngineBase.cs +++ b/OpenNest.Engine/NestEngineBase.cs @@ -177,7 +177,7 @@ namespace OpenNest // --- Protected utilities --- - protected static void ReportProgress( + internal static void ReportProgress( IProgress progress, NestPhase phase, int plateNumber, diff --git a/OpenNest.Engine/PairFiller.cs b/OpenNest.Engine/PairFiller.cs new file mode 100644 index 0000000..b448c96 --- /dev/null +++ b/OpenNest.Engine/PairFiller.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using OpenNest.Engine.BestFit; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest +{ + /// + /// Fills a work area using interlocking part pairs from BestFitCache. + /// Extracted from DefaultNestEngine.FillWithPairs. + /// + public class PairFiller + { + private readonly Size plateSize; + private readonly double partSpacing; + + public PairFiller(Size plateSize, double partSpacing) + { + this.plateSize = plateSize; + this.partSpacing = partSpacing; + } + + public List Fill(NestItem item, Box workArea, + int plateNumber = 0, + CancellationToken token = default, + IProgress progress = null) + { + var bestFits = BestFitCache.GetOrCompute( + item.Drawing, plateSize.Width, plateSize.Length, partSpacing); + + var candidates = SelectPairCandidates(bestFits, workArea); + Debug.WriteLine($"[PairFiller] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}"); + Debug.WriteLine($"[PairFiller] Plate: {plateSize.Width:F2}x{plateSize.Length:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}"); + + List best = null; + var bestScore = default(FillScore); + var sinceImproved = 0; + + try + { + for (var i = 0; i < candidates.Count; i++) + { + token.ThrowIfCancellationRequested(); + + var result = candidates[i]; + var pairParts = result.BuildParts(item.Drawing); + var angles = result.HullAngles; + var engine = new FillLinear(workArea, partSpacing); + var filled = DefaultNestEngine.FillPattern(engine, pairParts, angles, workArea); + + if (filled != null && filled.Count > 0) + { + var score = FillScore.Compute(filled, workArea); + if (best == null || score > bestScore) + { + best = filled; + bestScore = score; + sinceImproved = 0; + } + else + { + sinceImproved++; + } + } + else + { + sinceImproved++; + } + + NestEngineBase.ReportProgress(progress, NestPhase.Pairs, plateNumber, best, workArea, + $"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts"); + + // Early exit: stop if we've tried enough candidates without improvement. + if (i >= 9 && sinceImproved >= 10) + { + Debug.WriteLine($"[PairFiller] Early exit at {i + 1}/{candidates.Count} — no improvement in last {sinceImproved} candidates"); + break; + } + } + } + catch (OperationCanceledException) + { + Debug.WriteLine("[PairFiller] Cancelled mid-phase, using results so far"); + } + + Debug.WriteLine($"[PairFiller] Best pair result: {bestScore.Count} parts, density={bestScore.Density:P1}"); + return best ?? new List(); + } + + private List SelectPairCandidates(List bestFits, Box workArea) + { + var kept = bestFits.Where(r => r.Keep).ToList(); + var top = kept.Take(50).ToList(); + + var workShortSide = System.Math.Min(workArea.Width, workArea.Length); + var plateShortSide = System.Math.Min(plateSize.Width, plateSize.Length); + + if (workShortSide < plateShortSide * 0.5) + { + var stripCandidates = bestFits + .Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon + && r.Utilization >= 0.3) + .OrderByDescending(r => r.Utilization); + + var existing = new HashSet(top); + + foreach (var r in stripCandidates) + { + if (top.Count >= 100) + break; + + if (existing.Add(r)) + top.Add(r); + } + + Debug.WriteLine($"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})"); + } + + return top; + } + } +} diff --git a/OpenNest.Tests/PairFillerTests.cs b/OpenNest.Tests/PairFillerTests.cs new file mode 100644 index 0000000..d2199b7 --- /dev/null +++ b/OpenNest.Tests/PairFillerTests.cs @@ -0,0 +1,63 @@ +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class PairFillerTests +{ + private static Drawing MakeRectDrawing(double w, double h) + { + var pgm = new OpenNest.CNC.Program(); + pgm.Codes.Add(new OpenNest.CNC.RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, 0))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(w, h))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, h))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0))); + return new Drawing("rect", pgm); + } + + [Fact] + public void Fill_ReturnsPartsForSimpleDrawing() + { + var plateSize = new Size(120, 60); + var filler = new PairFiller(plateSize, 0.5); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var workArea = new Box(0, 0, 120, 60); + + var parts = filler.Fill(item, workArea); + + Assert.NotNull(parts); + // Pair filling may or may not find interlocking pairs for rectangles, + // but should return a non-null list. + } + + [Fact] + public void Fill_EmptyResult_WhenPartTooLarge() + { + var plateSize = new Size(10, 10); + var filler = new PairFiller(plateSize, 0.5); + var item = new NestItem { Drawing = MakeRectDrawing(20, 20) }; + var workArea = new Box(0, 0, 10, 10); + + var parts = filler.Fill(item, workArea); + + Assert.NotNull(parts); + Assert.Empty(parts); + } + + [Fact] + public void Fill_RespectsCancellation() + { + var cts = new System.Threading.CancellationTokenSource(); + cts.Cancel(); + + var plateSize = new Size(120, 60); + var filler = new PairFiller(plateSize, 0.5); + var item = new NestItem { Drawing = MakeRectDrawing(20, 10) }; + var workArea = new Box(0, 0, 120, 60); + + var parts = filler.Fill(item, workArea, token: cts.Token); + + // Should return empty or partial — not throw + Assert.NotNull(parts); + } +}