From 42d404577b4d44ae32cd914bd480a6d5c642986e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Mar 2026 21:39:01 -0400 Subject: [PATCH] feat: add StripNestEngine with strip-based multi-drawing nesting New NestEngineBase subclass that dedicates a tight strip to the largest-area drawing and fills the remnant with remaining drawings. Tries both bottom and left orientations, uses a shrink loop to find the tightest strip, and picks the denser result. Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/StripDirection.cs | 8 + OpenNest.Engine/StripNestEngine.cs | 278 +++++++++++++++++++++++++++++ OpenNest.Engine/StripNestResult.cs | 14 ++ 3 files changed, 300 insertions(+) create mode 100644 OpenNest.Engine/StripDirection.cs create mode 100644 OpenNest.Engine/StripNestEngine.cs create mode 100644 OpenNest.Engine/StripNestResult.cs diff --git a/OpenNest.Engine/StripDirection.cs b/OpenNest.Engine/StripDirection.cs new file mode 100644 index 0000000..10005c8 --- /dev/null +++ b/OpenNest.Engine/StripDirection.cs @@ -0,0 +1,8 @@ +namespace OpenNest +{ + public enum StripDirection + { + Bottom, + Left + } +} diff --git a/OpenNest.Engine/StripNestEngine.cs b/OpenNest.Engine/StripNestEngine.cs new file mode 100644 index 0000000..3050c4e --- /dev/null +++ b/OpenNest.Engine/StripNestEngine.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest +{ + public class StripNestEngine : NestEngineBase + { + private const int MaxShrinkIterations = 20; + + public StripNestEngine(Plate plate) : base(plate) + { + } + + public override string Name => "Strip"; + + public override string Description => "Strip-based nesting for mixed-drawing layouts"; + + /// + /// Single-item fill delegates to DefaultNestEngine. + /// The strip strategy adds value for multi-drawing nesting, not single-item fills. + /// + public override List Fill(NestItem item, Box workArea, + IProgress progress, CancellationToken token) + { + var inner = new DefaultNestEngine(Plate); + return inner.Fill(item, workArea, progress, token); + } + + /// + /// Selects the item that consumes the most plate area (bounding box area x quantity). + /// Returns the index into the items list. + /// + private static int SelectStripItemIndex(List items, Box workArea) + { + var bestIndex = 0; + var bestArea = 0.0; + + for (var i = 0; i < items.Count; i++) + { + var bbox = items[i].Drawing.Program.BoundingBox(); + var qty = items[i].Quantity > 0 + ? items[i].Quantity + : (int)(workArea.Area() / bbox.Area()); + var totalArea = bbox.Area() * qty; + + if (totalArea > bestArea) + { + bestArea = totalArea; + bestIndex = i; + } + } + + return bestIndex; + } + + /// + /// Estimates the strip dimension (height for bottom, width for left) needed + /// to fit the target quantity. Tries 0 deg and 90 deg rotations and picks the shorter. + /// This is only an estimate for the shrink loop starting point — the actual fill + /// uses DefaultNestEngine.Fill which tries many rotation angles internally. + /// + private static double EstimateStripDimension(NestItem item, double stripLength, double maxDimension) + { + var bbox = item.Drawing.Program.BoundingBox(); + var qty = item.Quantity > 0 + ? item.Quantity + : System.Math.Max(1, (int)(stripLength * maxDimension / bbox.Area())); + + // At 0 deg: parts per row along strip length, strip dimension is bbox.Length + var perRow0 = (int)(stripLength / bbox.Width); + var rows0 = perRow0 > 0 ? (int)System.Math.Ceiling((double)qty / perRow0) : int.MaxValue; + var dim0 = rows0 * bbox.Length; + + // At 90 deg: rotated bounding box (Width and Length swap) + var perRow90 = (int)(stripLength / bbox.Length); + var rows90 = perRow90 > 0 ? (int)System.Math.Ceiling((double)qty / perRow90) : int.MaxValue; + var dim90 = rows90 * bbox.Width; + + var estimate = System.Math.Min(dim0, dim90); + + // Clamp to available dimension + return System.Math.Min(estimate, maxDimension); + } + + /// + /// Multi-drawing strip nesting strategy. + /// Picks the largest-area drawing for strip treatment, finds the tightest strip + /// in both bottom and left orientations, fills remnants with remaining drawings, + /// and returns the denser result. + /// + public List Nest(List items, + IProgress progress, CancellationToken token) + { + if (items == null || items.Count == 0) + return new List(); + + var workArea = Plate.WorkArea(); + + // Select which item gets the strip treatment. + var stripIndex = SelectStripItemIndex(items, workArea); + var stripItem = items[stripIndex]; + var remainderItems = items.Where((_, i) => i != stripIndex).ToList(); + + // Try both orientations. + var bottomResult = TryOrientation(StripDirection.Bottom, stripItem, remainderItems, workArea, token); + var leftResult = TryOrientation(StripDirection.Left, stripItem, remainderItems, workArea, token); + + // Pick the better result. + if (bottomResult.Score >= leftResult.Score) + return bottomResult.Parts; + + return leftResult.Parts; + } + + private StripNestResult TryOrientation(StripDirection direction, NestItem stripItem, + List remainderItems, Box workArea, CancellationToken token) + { + var result = new StripNestResult { Direction = direction }; + + if (token.IsCancellationRequested) + return result; + + // Estimate initial strip dimension. + var stripLength = direction == StripDirection.Bottom ? workArea.Width : workArea.Length; + var maxDimension = direction == StripDirection.Bottom ? workArea.Length : workArea.Width; + var estimatedDim = EstimateStripDimension(stripItem, stripLength, maxDimension); + + // Create the initial strip box. + var stripBox = direction == StripDirection.Bottom + ? new Box(workArea.X, workArea.Y, workArea.Width, estimatedDim) + : new Box(workArea.X, workArea.Y, estimatedDim, workArea.Length); + + // Initial fill using DefaultNestEngine (composition, not inheritance). + var inner = new DefaultNestEngine(Plate); + var stripParts = inner.Fill( + new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity }, + stripBox, null, token); + + if (stripParts == null || stripParts.Count == 0) + return result; + + // Measure actual strip dimension from placed parts. + var placedBox = stripParts.Cast().GetBoundingBox(); + var actualDim = direction == StripDirection.Bottom + ? placedBox.Top - workArea.Y + : placedBox.Right - workArea.X; + + var bestParts = stripParts; + var bestDim = actualDim; + var targetCount = stripParts.Count; + + // Shrink loop: reduce strip dimension by PartSpacing until count drops. + for (var i = 0; i < MaxShrinkIterations; i++) + { + if (token.IsCancellationRequested) + break; + + var trialDim = bestDim - Plate.PartSpacing; + if (trialDim <= 0) + break; + + var trialBox = direction == StripDirection.Bottom + ? new Box(workArea.X, workArea.Y, workArea.Width, trialDim) + : new Box(workArea.X, workArea.Y, trialDim, workArea.Length); + + var trialInner = new DefaultNestEngine(Plate); + var trialParts = trialInner.Fill( + new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity }, + trialBox, null, token); + + if (trialParts == null || trialParts.Count < targetCount) + break; + + // Same count in a tighter strip — keep going. + bestParts = trialParts; + var trialPlacedBox = trialParts.Cast().GetBoundingBox(); + bestDim = direction == StripDirection.Bottom + ? trialPlacedBox.Top - workArea.Y + : trialPlacedBox.Right - workArea.X; + } + + // Build remnant box with spacing gap. + var spacing = Plate.PartSpacing; + var remnantBox = direction == StripDirection.Bottom + ? new Box(workArea.X, workArea.Y + bestDim + spacing, + workArea.Width, workArea.Length - bestDim - spacing) + : new Box(workArea.X + bestDim + spacing, workArea.Y, + workArea.Width - bestDim - spacing, workArea.Length); + + // Collect all parts. + var allParts = new List(bestParts); + + // If strip item was only partially placed, add leftovers to remainder. + var placed = bestParts.Count; + var leftover = stripItem.Quantity > 0 ? stripItem.Quantity - placed : 0; + var effectiveRemainder = new List(remainderItems); + + if (leftover > 0) + { + effectiveRemainder.Add(new NestItem + { + Drawing = stripItem.Drawing, + Quantity = leftover + }); + } + + // Sort remainder by descending bounding box area x quantity. + effectiveRemainder = effectiveRemainder + .OrderByDescending(i => + { + var bb = i.Drawing.Program.BoundingBox(); + return bb.Area() * (i.Quantity > 0 ? i.Quantity : 1); + }) + .ToList(); + + // Fill remnant with remainder items, shrinking the available area after each. + if (remnantBox.Width > 0 && remnantBox.Length > 0) + { + var currentRemnant = remnantBox; + + foreach (var item in effectiveRemainder) + { + if (token.IsCancellationRequested) + break; + + if (currentRemnant.Width <= 0 || currentRemnant.Length <= 0) + break; + + var remnantInner = new DefaultNestEngine(Plate); + var remnantParts = remnantInner.Fill( + new NestItem { Drawing = item.Drawing, Quantity = item.Quantity }, + currentRemnant, null, token); + + if (remnantParts != null && remnantParts.Count > 0) + { + allParts.AddRange(remnantParts); + + // Shrink remnant to avoid overlap with next item. + var usedBox = remnantParts.Cast().GetBoundingBox(); + currentRemnant = ComputeRemainderWithin(currentRemnant, usedBox, spacing); + } + } + } + + result.Parts = allParts; + result.StripBox = direction == StripDirection.Bottom + ? new Box(workArea.X, workArea.Y, workArea.Width, bestDim) + : new Box(workArea.X, workArea.Y, bestDim, workArea.Length); + result.RemnantBox = remnantBox; + result.Score = FillScore.Compute(allParts, workArea); + + return result; + } + + /// + /// Computes the largest usable remainder within a work area after a portion has been used. + /// Picks whichever is larger: the horizontal strip to the right, or the vertical strip above. + /// + private static Box ComputeRemainderWithin(Box workArea, Box usedBox, double spacing) + { + var hWidth = workArea.Right - usedBox.Right - spacing; + var hStrip = hWidth > 0 + ? new Box(usedBox.Right + spacing, workArea.Y, hWidth, workArea.Length) + : Box.Empty; + + var vHeight = workArea.Top - usedBox.Top - spacing; + var vStrip = vHeight > 0 + ? new Box(workArea.X, usedBox.Top + spacing, workArea.Width, vHeight) + : Box.Empty; + + return hStrip.Area() >= vStrip.Area() ? hStrip : vStrip; + } + } +} diff --git a/OpenNest.Engine/StripNestResult.cs b/OpenNest.Engine/StripNestResult.cs new file mode 100644 index 0000000..f849d8e --- /dev/null +++ b/OpenNest.Engine/StripNestResult.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest +{ + internal class StripNestResult + { + public List Parts { get; set; } = new(); + public Box StripBox { get; set; } + public Box RemnantBox { get; set; } + public FillScore Score { get; set; } + public StripDirection Direction { get; set; } + } +}