From 93fbe1a9f811849e92903a6f9c871486d2ac7893 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 13 Mar 2026 08:26:32 -0400 Subject: [PATCH] feat(engine): add FindBestFill and FillWithPairs overloads with progress and cancellation Co-Authored-By: Claude Opus 4.6 --- OpenNest.Engine/NestEngine.cs | 171 ++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/OpenNest.Engine/NestEngine.cs b/OpenNest.Engine/NestEngine.cs index 0893ed5..3e0a1b7 100644 --- a/OpenNest.Engine/NestEngine.cs +++ b/OpenNest.Engine/NestEngine.cs @@ -1,6 +1,8 @@ +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; @@ -19,6 +21,8 @@ namespace OpenNest public NestDirection NestDirection { get; set; } + public int PlateNumber { get; set; } + public bool Fill(NestItem item) { return Fill(item, Plate.WorkArea()); @@ -134,6 +138,99 @@ namespace OpenNest return best; } + private List FindBestFill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + List best = null; + + try + { + var bestRotation = RotationAnalysis.FindBestRotation(item); + var engine = new FillLinear(workArea, Plate.PartSpacing); + var angles = new List { bestRotation, bestRotation + Angle.HalfPI }; + + var testPart = new Part(item.Drawing); + if (!bestRotation.IsEqualTo(0)) + testPart.Rotate(bestRotation); + testPart.UpdateBounds(); + + var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Length); + var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length); + + if (workAreaShortSide < partLongestSide) + { + var step = Angle.ToRadians(5); + for (var a = 0.0; a < System.Math.PI; a += step) + { + if (!angles.Any(existing => existing.IsEqualTo(a))) + angles.Add(a); + } + } + + // Linear phase + var linearBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); + + System.Threading.Tasks.Parallel.ForEach(angles, + new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, + angle => + { + var localEngine = new FillLinear(workArea, Plate.PartSpacing); + var h = localEngine.Fill(item.Drawing, angle, NestDirection.Horizontal); + var v = localEngine.Fill(item.Drawing, angle, NestDirection.Vertical); + + if (h != null && h.Count > 0) + linearBag.Add((FillScore.Compute(h, workArea), h)); + if (v != null && v.Count > 0) + linearBag.Add((FillScore.Compute(v, workArea), v)); + }); + + var bestScore = default(FillScore); + + foreach (var (score, parts) in linearBag) + { + if (best == null || score > bestScore) + { + best = parts; + bestScore = score; + } + } + + var bestLinearScore = best != null ? FillScore.Compute(best, workArea) : default; + Debug.WriteLine($"[FindBestFill] Linear: {bestLinearScore.Count} parts, density={bestLinearScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}"); + + ReportProgress(progress, NestPhase.Linear, PlateNumber, best, workArea); + token.ThrowIfCancellationRequested(); + + // RectBestFit phase + var rectResult = FillRectangleBestFit(item, workArea); + Debug.WriteLine($"[FindBestFill] RectBestFit: {rectResult?.Count ?? 0} parts"); + + if (IsBetterFill(rectResult, best, workArea)) + { + best = rectResult; + ReportProgress(progress, NestPhase.RectBestFit, PlateNumber, best, workArea); + } + + token.ThrowIfCancellationRequested(); + + // Pairs phase + var pairResult = FillWithPairs(item, workArea, token); + Debug.WriteLine($"[FindBestFill] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}"); + + if (IsBetterFill(pairResult, best, workArea)) + { + best = pairResult; + ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea); + } + } + catch (OperationCanceledException) + { + Debug.WriteLine("[FindBestFill] Cancelled, returning current best"); + } + + return best ?? new List(); + } + public bool Fill(List groupParts, Box workArea) { if (groupParts == null || groupParts.Count == 0) @@ -249,6 +346,54 @@ namespace OpenNest return best ?? new List(); } + private List FillWithPairs(NestItem item, Box workArea, CancellationToken token) + { + var bestFits = BestFitCache.GetOrCompute( + item.Drawing, Plate.Size.Width, Plate.Size.Length, + Plate.PartSpacing); + + var candidates = SelectPairCandidates(bestFits, workArea); + Debug.WriteLine($"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}"); + + var resultBag = new System.Collections.Concurrent.ConcurrentBag<(FillScore score, List parts)>(); + + try + { + System.Threading.Tasks.Parallel.For(0, candidates.Count, + new System.Threading.Tasks.ParallelOptions { CancellationToken = token }, + i => + { + var result = candidates[i]; + var pairParts = result.BuildParts(item.Drawing); + var angles = RotationAnalysis.FindHullEdgeAngles(pairParts); + var engine = new FillLinear(workArea, Plate.PartSpacing); + var filled = FillPattern(engine, pairParts, angles, workArea); + + if (filled != null && filled.Count > 0) + resultBag.Add((FillScore.Compute(filled, workArea), filled)); + }); + } + catch (OperationCanceledException) + { + Debug.WriteLine("[FillWithPairs] Cancelled mid-phase, using results so far"); + } + + List best = null; + var bestScore = default(FillScore); + + foreach (var (score, parts) in resultBag) + { + if (best == null || score > bestScore) + { + best = parts; + bestScore = score; + } + } + + Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, remnant={bestScore.UsableRemnantArea:F1}, density={bestScore.Density:P1}"); + return best ?? new List(); + } + /// /// Selects pair candidates to try for the given work area. Always includes /// the top 50 by area. For narrow work areas, also includes all pairs whose @@ -533,5 +678,31 @@ namespace OpenNest return best; } + private static void ReportProgress( + IProgress progress, + NestPhase phase, + int plateNumber, + List best, + Box workArea) + { + if (progress == null || best == null || best.Count == 0) + return; + + var score = FillScore.Compute(best, workArea); + var clonedParts = new List(best.Count); + + foreach (var part in best) + clonedParts.Add((Part)part.Clone()); + + progress.Report(new NestProgress + { + Phase = phase, + PlateNumber = plateNumber, + BestPartCount = score.Count, + BestDensity = score.Density, + UsableRemnantArea = score.UsableRemnantArea, + BestParts = clonedParts + }); + } } }