From e3b388464deb423b4e1e987e2f0a48521f76f703 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 30 Mar 2026 00:38:44 -0400 Subject: [PATCH] feat: fast-path fill and dual-axis shrink for low quantities For qty 1-2, skip the full 6-strategy pipeline: place a single part or a best-fit pair directly. For larger low quantities, shrink the work area in both dimensions (sqrt scaling with 2x margin) before running strategies, with fallback to full area if insufficient. - Add TryFillSmallQuantity fast path (qty=1 single, qty=2 best-fit pair) - Add ShrinkWorkArea with proportional dual-axis reduction - Extract RunFillPipeline helper from Fill() - Make ShrinkFiller.EstimateStartBox internal with margin parameter - Add MaxQuantity to FillContext for strategy-level access Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/DefaultNestEngine.cs | 177 ++++++++++++++++++++-- OpenNest.Engine/Fill/ShrinkFiller.cs | 6 +- OpenNest.Engine/Strategies/FillContext.cs | 1 + 3 files changed, 167 insertions(+), 17 deletions(-) diff --git a/OpenNest.Engine/DefaultNestEngine.cs b/OpenNest.Engine/DefaultNestEngine.cs index 9912ea2..d628715 100644 --- a/OpenNest.Engine/DefaultNestEngine.cs +++ b/OpenNest.Engine/DefaultNestEngine.cs @@ -1,7 +1,9 @@ using OpenNest.Engine; +using OpenNest.Engine.BestFit; using OpenNest.Engine.Fill; using OpenNest.Engine.Strategies; using OpenNest.Geometry; +using OpenNest.Math; using OpenNest.RectanglePacking; using System; using System.Collections.Generic; @@ -45,24 +47,50 @@ namespace OpenNest PhaseResults.Clear(); AngleResults.Clear(); - var context = new FillContext + // Fast path: for very small quantities, skip the full strategy pipeline. + if (item.Quantity > 0 && item.Quantity <= 2) { - Item = item, - WorkArea = workArea, - Plate = Plate, - PlateNumber = PlateNumber, - Token = token, - Progress = progress, - Policy = BuildPolicy(), - }; + var fast = TryFillSmallQuantity(item, workArea); + if (fast != null && fast.Count >= item.Quantity) + { + Debug.WriteLine($"[Fill] Fast path: placed {fast.Count} parts for qty={item.Quantity}"); + WinnerPhase = NestPhase.Pairs; + ReportProgress(progress, new ProgressReport + { + Phase = WinnerPhase, + PlateNumber = PlateNumber, + Parts = fast, + WorkArea = workArea, + Description = $"Fast path: {fast.Count} parts", + IsOverallBest = true, + }); + return fast; + } + } - RunPipeline(context); + // For low quantities, shrink the work area in both dimensions to avoid + // running expensive strategies against the full plate. + var effectiveWorkArea = workArea; + if (item.Quantity > 0) + { + effectiveWorkArea = ShrinkWorkArea(item, workArea, Plate.PartSpacing); - // PhaseResults already synced during RunPipeline. - AngleResults.AddRange(context.AngleResults); - WinnerPhase = context.WinnerPhase; + if (effectiveWorkArea != workArea) + Debug.WriteLine($"[Fill] Low-qty shrink: {item.Quantity} requested, " + + $"from {workArea.Width:F1}x{workArea.Length:F1} " + + $"to {effectiveWorkArea.Width:F1}x{effectiveWorkArea.Length:F1}"); + } - var best = context.CurrentBest ?? new List(); + var best = RunFillPipeline(item, effectiveWorkArea, progress, token); + + // Fallback: if the reduced area didn't yield enough, retry with full area. + if (item.Quantity > 0 && best.Count < item.Quantity && effectiveWorkArea != workArea) + { + Debug.WriteLine($"[Fill] Low-qty fallback: got {best.Count}, need {item.Quantity}, retrying full area"); + PhaseResults.Clear(); + AngleResults.Clear(); + best = RunFillPipeline(item, workArea, progress, token); + } if (item.Quantity > 0 && best.Count > item.Quantity) best = ShrinkFiller.TrimToCount(best, item.Quantity, TrimAxis); @@ -80,6 +108,127 @@ namespace OpenNest return best; } + /// + /// Fast path for qty 1-2: place a single part or a best-fit pair + /// without running the full strategy pipeline. + /// + private List TryFillSmallQuantity(NestItem item, Box workArea) + { + if (item.Quantity == 1) + return TryPlaceSingle(item.Drawing, workArea); + + if (item.Quantity == 2) + return TryPlaceBestFitPair(item.Drawing, workArea); + + return null; + } + + private static List TryPlaceSingle(Drawing drawing, Box workArea) + { + var part = Part.CreateAtOrigin(drawing); + if (part.BoundingBox.Width > workArea.Width + Tolerance.Epsilon || + part.BoundingBox.Length > workArea.Length + Tolerance.Epsilon) + return null; + + part.Offset(workArea.Location - part.BoundingBox.Location); + return new List { part }; + } + + private List TryPlaceBestFitPair(Drawing drawing, Box workArea) + { + var bestFits = BestFitCache.GetOrCompute( + drawing, Plate.Size.Length, Plate.Size.Width, Plate.PartSpacing); + + var best = bestFits.FirstOrDefault(r => r.Keep); + if (best == null) + return null; + + var parts = best.BuildParts(drawing); + + // BuildParts positions at origin — offset to work area. + var bbox = ((IEnumerable)parts).GetBoundingBox(); + var offset = workArea.Location - bbox.Location; + foreach (var p in parts) + { + p.Offset(offset); + p.UpdateBounds(); + } + + // Verify pair fits in work area. + bbox = ((IEnumerable)parts).GetBoundingBox(); + if (bbox.Width > workArea.Width + Tolerance.Epsilon || + bbox.Length > workArea.Length + Tolerance.Epsilon) + return null; + + return parts; + } + + /// + /// Shrinks the work area in both dimensions proportionally when the + /// requested quantity is much less than the plate capacity. + /// + private static Box ShrinkWorkArea(NestItem item, Box workArea, double spacing) + { + var bbox = item.Drawing.Program.BoundingBox(); + if (bbox.Width <= 0 || bbox.Length <= 0) + return workArea; + + var bin = new Bin { Size = new Size(workArea.Width, workArea.Length) }; + var packItem = new Item { Size = new Size(bbox.Width + spacing, bbox.Length + spacing) }; + var packer = new FillBestFit(bin); + packer.Fill(packItem); + var fullCount = bin.Items.Count; + + if (fullCount <= 0 || fullCount <= item.Quantity) + return workArea; + + // Scale both dimensions by sqrt(ratio) so the area shrinks + // proportionally. 2x margin gives strategies room to optimize. + var ratio = (double)item.Quantity / fullCount; + var scale = System.Math.Sqrt(ratio) * 2.0; + + var newWidth = workArea.Width * scale; + var newLength = workArea.Length * scale; + + // Ensure at least one part fits. + var minWidth = bbox.Width + spacing * 2; + var minLength = bbox.Length + spacing * 2; + newWidth = System.Math.Max(newWidth, minWidth); + newLength = System.Math.Max(newLength, minLength); + + // Clamp to original dimensions. + newWidth = System.Math.Min(newWidth, workArea.Width); + newLength = System.Math.Min(newLength, workArea.Length); + + if (newWidth >= workArea.Width && newLength >= workArea.Length) + return workArea; + + return new Box(workArea.X, workArea.Y, newWidth, newLength); + } + + private List RunFillPipeline(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + var context = new FillContext + { + Item = item, + WorkArea = workArea, + Plate = Plate, + PlateNumber = PlateNumber, + Token = token, + Progress = progress, + Policy = BuildPolicy(), + MaxQuantity = item.Quantity, + }; + + RunPipeline(context); + + AngleResults.AddRange(context.AngleResults); + WinnerPhase = context.WinnerPhase; + + return context.CurrentBest ?? new List(); + } + public override List Fill(List groupParts, Box workArea, IProgress progress, CancellationToken token) { diff --git a/OpenNest.Engine/Fill/ShrinkFiller.cs b/OpenNest.Engine/Fill/ShrinkFiller.cs index b772454..33ce056 100644 --- a/OpenNest.Engine/Fill/ShrinkFiller.cs +++ b/OpenNest.Engine/Fill/ShrinkFiller.cs @@ -94,8 +94,8 @@ namespace OpenNest.Engine.Fill /// that fits roughly the target count. Scales the shrink axis proportionally /// from the full-area count down to the target, with margin. /// - private static Box EstimateStartBox(NestItem item, Box box, - double spacing, ShrinkAxis axis, int targetCount) + internal static Box EstimateStartBox(NestItem item, Box box, + double spacing, ShrinkAxis axis, int targetCount, double marginFactor = 1.3) { var bbox = item.Drawing.Program.BoundingBox(); if (bbox.Width <= 0 || bbox.Length <= 0) @@ -115,7 +115,7 @@ namespace OpenNest.Engine.Fill // Scale dimension proportionally: target/full * maxDim, with margin. var ratio = (double)targetCount / fullCount; - var estimate = maxDim * ratio * 1.3; + var estimate = maxDim * ratio * marginFactor; estimate = System.Math.Min(estimate, maxDim); if (estimate <= 0 || estimate >= maxDim) diff --git a/OpenNest.Engine/Strategies/FillContext.cs b/OpenNest.Engine/Strategies/FillContext.cs index 8f43f76..733b39a 100644 --- a/OpenNest.Engine/Strategies/FillContext.cs +++ b/OpenNest.Engine/Strategies/FillContext.cs @@ -16,6 +16,7 @@ namespace OpenNest.Engine.Strategies public CancellationToken Token { get; init; } public IProgress Progress { get; init; } public FillPolicy Policy { get; init; } + public int MaxQuantity { get; init; } public PartType PartType { get; set; } public List CurrentBest { get; set; }