using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; using OpenNest.Engine; using OpenNest.Engine.BestFit; using OpenNest.Engine.Strategies; using OpenNest.Geometry; using OpenNest.Math; namespace OpenNest.Engine.Fill { /// /// Defines the /// public class PairFillResult { /// /// Gets or sets the Parts /// public List Parts { get; set; } = new List(); /// /// Gets or sets the BestFits /// public List BestFits { get; set; } } /// /// Fills a work area using interlocking part pairs from BestFitCache /// public class PairFiller { /// /// Defines the EarlyExitMinTried /// private const int EarlyExitMinTried = 10; /// /// Defines the EarlyExitStaleLimit /// private const int EarlyExitStaleLimit = 10; /// /// Candidates evaluated together, against the best count and work area from before the /// batch. Fixed rather than scaled to the processor count, because it decides which /// candidates are tried and so which layout wins: every machine must nest alike. /// private const int CandidateBatchSize = 8; /// /// Defines the plate /// private readonly Plate plate; /// /// Defines the plateSize /// private readonly Size plateSize; /// /// Defines the partSpacing /// private readonly double partSpacing; /// /// Defines the comparer /// private readonly IFillComparer comparer; /// /// Defines the dedup /// private readonly GridDedup dedup; /// /// Defines the candidateSelector /// private readonly PairCandidateSelector candidateSelector; /// /// Defines the remnantFiller /// private readonly PairRemnantFiller remnantFiller; /// /// Initializes a new instance of the class. /// /// The plate /// The comparer /// The dedup public PairFiller(Plate plate, IFillComparer comparer = null, GridDedup dedup = null) { this.plate = plate; this.plateSize = plate.Size; this.partSpacing = plate.PartSpacing; this.comparer = comparer ?? new DefaultFillComparer(); this.dedup = dedup ?? new GridDedup(); this.candidateSelector = new PairCandidateSelector(plateSize, partSpacing); this.remnantFiller = new PairRemnantFiller(plate, this.comparer); } /// /// The Fill /// /// The item /// The workArea /// The token /// The reportProgress /// The public PairFillResult Fill( NestItem item, Box workArea, CancellationToken token = default, Action, string> reportProgress = null ) { // The first computation for a drawing can take a minute; say so rather than look stuck. if (!BestFitCache.IsCached(item.Drawing, plateSize.Length, plateSize.Width, partSpacing)) reportProgress?.Invoke(null, "Computing best-fit pairs (may take a minute)..."); List bestFits; try { bestFits = BestFitCache.GetOrCompute( item.Drawing, plateSize.Length, plateSize.Width, partSpacing, token ); } catch (OperationCanceledException) { // The pairs finish computing in the background and are cached for the next fill. return new PairFillResult(); } var candidates = candidateSelector.Select(bestFits, workArea); Debug.WriteLine( $"[PairFiller] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}" ); Debug.WriteLine( $"[PairFiller] Plate: {plateSize.Length:F2}x{plateSize.Width:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}" ); var targetCount = item.Quantity > 0 ? item.Quantity : 0; var parts = EvaluateCandidates( candidates, item.Drawing, workArea, targetCount, bestFits, token, reportProgress ); parts = EvaluateHerringbones( item.Drawing, workArea, parts, candidates, bestFits, token, reportProgress ); return new PairFillResult { Parts = parts, BestFits = bestFits }; } /// /// Tiles the drawing's herringbone pairs (), one group /// per part rotation, with the same grid and remnant fill as the best-fit pairs, and /// returns the better of them and by the comparer; a tie keeps /// . /// private List EvaluateHerringbones( Drawing drawing, Box workArea, List best, List candidates, List bestFits, CancellationToken token, Action, string> reportProgress ) { try { var groups = HerringbonePatterns.Build(drawing, partSpacing, token); if (groups.Count == 0) return best; var maxUtilization = candidates.Count > 0 ? candidates.Max(c => c.Utilization) : 1.0; var partBox = drawing.Program.BoundingBox(); var partArea = System.Math.Max(partBox.Width * partBox.Length, 1); var minCountToBeat = best?.Count ?? 0; var results = new List[groups.Count]; Parallel.For( 0, groups.Count, new ParallelOptions { CancellationToken = token }, i => { results[i] = EvaluateCandidate( groups[i], drawing, workArea, minCountToBeat, maxUtilization, partArea, bestFits, token ); } ); // In rotation order, so the same layout wins however the workers finish. foreach (var result in results) { if (comparer.IsBetter(result, best, workArea)) { best = result; reportProgress?.Invoke(best, $"Pairs: herringbone, best = {best.Count} parts"); } } } catch (OperationCanceledException) { Debug.WriteLine("[PairFiller] Canceled during herringbone pairs, using results so far"); } return best ?? new List(); } /// /// The EvaluateCandidates /// /// The candidates /// The drawing /// The workArea /// The targetCount /// The token /// The reportProgress /// The private List EvaluateCandidates( List candidates, Drawing drawing, Box workArea, int targetCount, List bestFits, CancellationToken token, Action, string> reportProgress ) { List best = null; var sinceImproved = 0; var effectiveWorkArea = workArea; var maxUtilization = candidates.Count > 0 ? candidates.Max(c => c.Utilization) : 1.0; // The remnant bound packs parts at the best pair utilization, which is measured against // the part's true area: its bounding box would undercount parts that interlock, such // as gussets, and prune candidates whose remnant could win. var partBox = drawing.Program.BoundingBox(); var partArea = drawing.Area > 0 ? drawing.Area : System.Math.Max(partBox.Width * partBox.Length, 1); // Evaluates the batches of a candidate list; with early exit, stops once that many // candidates in a row failed to improve and returns where it stopped. With requireMore, // a candidate replaces the best only with more parts, so a tie keeps the layout. int RunBatches(List list, bool earlyExit, bool requireMore) { for (var batchStart = 0; batchStart < list.Count; batchStart += CandidateBatchSize) { token.ThrowIfCancellationRequested(); var batchEnd = System.Math.Min(batchStart + CandidateBatchSize, list.Count); var results = EvaluateBatch( list, drawing, effectiveWorkArea, batchStart, batchEnd, best?.Count ?? 0, maxUtilization, partArea, bestFits, token ); (best, effectiveWorkArea, sinceImproved) = ProcessBatchResults( results, best, sinceImproved, workArea, effectiveWorkArea, targetCount, list.Count, batchStart, reportProgress, requireMore ); if (earlyExit && batchEnd >= EarlyExitMinTried && sinceImproved >= EarlyExitStaleLimit) { Debug.WriteLine( $"[PairFiller] Early exit at {batchEnd}/{list.Count} — no improvement in last {sinceImproved} candidates" ); return batchEnd; } } return list.Count; } try { var stoppedAt = RunBatches(candidates, earlyExit: true, requireMore: false); // The candidates are ranked by their grids alone, so the early exit can skip a // narrower grid whose leftover strip holds more parts than a fuller grid gains: // a column of gusset pairs slid to leave a strip for gussets turned 45 degrees. // Those whose grid and remnant estimate could still win are tried anyway. if (stoppedAt < candidates.Count && best != null && (targetCount <= 0 || best.Count < targetCount)) { var minWidth = RotationAnalysis.MinimumWidth(drawing); var promising = candidates .Skip(stoppedAt) .Where(c => candidateSelector.EstimateWithRemnant( c, workArea, minWidth, maxUtilization, partArea) > best.Count) .ToList(); if (promising.Count > 0) { Debug.WriteLine( $"[PairFiller] Trying {promising.Count} skipped candidates whose remnant could win" ); RunBatches(promising, earlyExit: false, requireMore: true); } } } catch (OperationCanceledException) { Debug.WriteLine("[PairFiller] Canceled mid-phase, using results so far"); } Debug.WriteLine($"[PairFiller] Best pair result: {best?.Count ?? 0} parts"); return best ?? new List(); } private List[] EvaluateBatch( List candidates, Drawing drawing, Box workArea, int batchStart, int batchEnd, int minCountToBeat, double maxUtilization, double partArea, List bestFits, CancellationToken token ) { var batchCount = batchEnd - batchStart; // Claimed in candidate order before the parallel loop: when two candidates share a // grid, the earlier one must get it however the threads are scheduled. var claims = new List<(Pattern Pattern, NestDirection Dir)>[batchCount]; for (var j = 0; j < batchCount; j++) { token.ThrowIfCancellationRequested(); claims[j] = ClaimGrids(candidates[batchStart + j], drawing, workArea); } var results = new List[batchCount]; Parallel.For( 0, batchCount, new ParallelOptions { CancellationToken = token }, j => { results[j] = EvaluateCandidate( claims[j], drawing, workArea, minCountToBeat, maxUtilization, partArea, bestFits, token ); } ); return results; } private (List Best, Box EffectiveWorkArea, int SinceImproved) ProcessBatchResults( List[] results, List best, int sinceImproved, Box workArea, Box effectiveWorkArea, int targetCount, int totalCandidates, int batchStart, Action, string> reportProgress, bool requireMore = false ) { for (var j = 0; j < results.Length; j++) { var placesMore = !requireMore || best == null || (results[j]?.Count ?? 0) > best.Count; if (placesMore && comparer.IsBetter(results[j], best, effectiveWorkArea)) { best = results[j]; sinceImproved = 0; effectiveWorkArea = TryReduceWorkArea(best, targetCount, workArea, effectiveWorkArea); } else { sinceImproved++; } reportProgress?.Invoke( best, $"Pairs: {batchStart + j + 1}/{totalCandidates} candidates, best = {best?.Count ?? 0} parts" ); } return (best, effectiveWorkArea, sinceImproved); } /// /// The TryReduceWorkArea /// /// The parts /// The targetCount /// The workArea /// The effectiveWorkArea /// The private static Box TryReduceWorkArea( List parts, int targetCount, Box workArea, Box effectiveWorkArea ) { if (targetCount <= 0 || parts.Count <= targetCount) return effectiveWorkArea; var reduced = ReduceWorkArea(parts, targetCount, workArea); if (reduced.Area() >= effectiveWorkArea.Area()) return effectiveWorkArea; Debug.WriteLine( $"[PairFiller] Reduced work area to {reduced.Width:F2}x{reduced.Length:F2} (trimmed to {targetCount + 1} parts)" ); return reduced; } /// /// Given parts that exceed targetCount, sorts by BoundingBox.Top descending, /// removes parts from the top until exactly targetCount remain, then returns /// the Top of the remaining parts as the new work area height to beat /// /// The parts /// The targetCount /// The workArea /// The private static Box ReduceWorkArea(List parts, int targetCount, Box workArea) { if (parts.Count <= targetCount) return workArea; var sorted = parts.OrderByDescending(p => p.BoundingBox.Top).ToList(); var trimCount = sorted.Count - targetCount; var remaining = sorted.Skip(trimCount).ToList(); var newTop = remaining.Max(p => p.BoundingBox.Top); return new Box( workArea.X, workArea.Y, workArea.Length, System.Math.Min(newTop - workArea.Y, workArea.Width) ); } /// /// Builds the candidate's tiling patterns and claims the grids no earlier candidate took. /// /// The candidate /// The drawing /// The workArea /// The patterns and directions left for this candidate to fill private List<(Pattern Pattern, NestDirection Dir)> ClaimGrids( BestFitResult candidate, Drawing drawing, Box workArea ) { var claimed = new List<(Pattern Pattern, NestDirection Dir)>(); var pairParts = candidate.BuildParts(drawing); foreach (var angle in BuildTilingAngles(candidate)) { var pattern = FillHelpers.BuildRotatedPattern(pairParts, angle); if (pattern.Parts.Count == 0) continue; foreach (var dir in new[] { NestDirection.Horizontal, NestDirection.Vertical }) { if (dedup.TryAdd(pattern.BoundingBox, workArea, dir)) claimed.Add((pattern, dir)); } } return claimed; } /// /// The EvaluateCandidate /// /// The grids this candidate claimed /// The drawing /// The workArea /// The minCountToBeat /// The maxUtilization /// The partArea /// The pairs the remnant strips are also filled with /// The token /// The private List EvaluateCandidate( List<(Pattern Pattern, NestDirection Dir)> claimed, Drawing drawing, Box workArea, int minCountToBeat, double maxUtilization, double partArea, List bestFits, CancellationToken token ) { // Phase 1: evaluate all grids (fast) var grids = new List<(List Parts, NestDirection Dir)>(); var engine = new FillLinear(workArea, partSpacing) { Label = "Pairs" }; foreach (var (pattern, dir) in claimed) { token.ThrowIfCancellationRequested(); var gridParts = engine.Fill(pattern, dir); if (gridParts != null && gridParts.Count > 0) grids.Add((gridParts, dir)); } if (grids.Count == 0) return null; // Sort by count descending so we try the best grids first grids.Sort((a, b) => b.Parts.Count.CompareTo(a.Parts.Count)); // Early abort: if the best grid + optimistic remnant can't beat the global best, skip Phase 2 if (minCountToBeat > 0) { var topCount = grids[0].Parts.Count; var optimisticRemnant = remnantFiller.EstimateUpperBound( grids[0].Parts, workArea, maxUtilization, partArea ); if (topCount + optimisticRemnant <= minCountToBeat) { Debug.WriteLine( $"[PairFiller] Skipping candidate: grid {topCount} + estimate {optimisticRemnant} <= best {minCountToBeat}" ); return null; } } // Phase 2: try remnant for each grid, skip if grid is too far behind List best = null; foreach (var (gridParts, dir) in grids) { token.ThrowIfCancellationRequested(); // If this grid + max possible remnant can't beat current best, skip if (best != null) { var remnantBound = remnantFiller.EstimateUpperBound( gridParts, workArea, maxUtilization, partArea ); if (gridParts.Count + remnantBound <= best.Count) break; // sorted descending, so remaining are even smaller } var remnantParts = remnantFiller.Fill(gridParts, drawing, workArea, token, bestFits); List total; if (remnantParts != null && remnantParts.Count > 0) { total = new List(gridParts.Count + remnantParts.Count); total.AddRange(gridParts); total.AddRange(remnantParts); } else { total = gridParts; } if (comparer.IsBetter(total, best, workArea)) best = total; } return best; } /// /// The BuildTilingAngles /// /// The candidate /// The internal static List BuildTilingAngles(BestFitResult candidate) { var angles = new List(candidate.HullAngles); var optAngle = -candidate.OptimalRotation; if (!angles.Any(a => a.IsEqualTo(optAngle))) angles.Add(optAngle); var optAngle90 = Angle.NormalizeRad(optAngle + Angle.HalfPI); if (!angles.Any(a => a.IsEqualTo(optAngle90))) angles.Add(optAngle90); return angles; } } }