From ef737ffa6dd506152ce0c859c5c2a8f9825f48a0 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Mar 2026 10:30:10 -0400 Subject: [PATCH] feat(engine): add IterativeShrinkFiller with dual-direction shrink selection Introduces IterativeShrinkFiller.Fill, which composes RemnantFiller and ShrinkFiller by wrapping the caller's fill function in a closure that tries both ShrinkAxis.Height and ShrinkAxis.Width and picks the better FillScore. Adds IterativeShrinkResult (Parts + Leftovers). Covers null/empty inputs and single-item placement with three passing xUnit tests. Co-Authored-By: Claude Sonnet 4.6 --- OpenNest.Engine/Fill/IterativeShrinkFiller.cs | 116 ++++++++++++++++++ OpenNest.Tests/IterativeShrinkFillerTests.cs | 59 +++++++++ 2 files changed, 175 insertions(+) create mode 100644 OpenNest.Engine/Fill/IterativeShrinkFiller.cs create mode 100644 OpenNest.Tests/IterativeShrinkFillerTests.cs diff --git a/OpenNest.Engine/Fill/IterativeShrinkFiller.cs b/OpenNest.Engine/Fill/IterativeShrinkFiller.cs new file mode 100644 index 0000000..46c6e6f --- /dev/null +++ b/OpenNest.Engine/Fill/IterativeShrinkFiller.cs @@ -0,0 +1,116 @@ +using OpenNest.Geometry; +using System; +using System.Collections.Generic; +using System.Threading; + +namespace OpenNest.Engine.Fill +{ + /// + /// Result returned by . + /// + public class IterativeShrinkResult + { + public List Parts { get; set; } = new List(); + public List Leftovers { get; set; } = new List(); + } + + /// + /// Composes and with + /// dual-direction shrink selection. Wraps the caller's fill function in a + /// closure that tries both and + /// , picks the better , + /// and passes the wrapper to . + /// + public static class IterativeShrinkFiller + { + public static IterativeShrinkResult Fill( + List items, + Box workArea, + Func> fillFunc, + double spacing, + CancellationToken token = default) + { + if (items == null || items.Count == 0) + return new IterativeShrinkResult(); + + // RemnantFiller.FillItems skips items with Quantity <= 0 (its localQty + // check treats them as "done"). Convert unlimited items to an estimated + // max capacity so they are actually processed. + var workItems = new List(items.Count); + var unlimitedDrawings = new HashSet(); + + foreach (var item in items) + { + if (item.Quantity <= 0) + { + var bbox = item.Drawing.Program.BoundingBox(); + var estimatedMax = bbox.Area() > 0 + ? (int)(workArea.Area() / bbox.Area()) * 2 + : 1000; + + unlimitedDrawings.Add(item.Drawing.Name); + workItems.Add(new NestItem + { + Drawing = item.Drawing, + Quantity = System.Math.Max(1, estimatedMax), + Priority = item.Priority, + StepAngle = item.StepAngle, + RotationStart = item.RotationStart, + RotationEnd = item.RotationEnd + }); + } + else + { + workItems.Add(item); + } + } + + var filler = new RemnantFiller(workArea, spacing); + + Func> shrinkWrapper = (ni, box) => + { + var heightResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Height, token); + var widthResult = ShrinkFiller.Shrink(fillFunc, ni, box, spacing, ShrinkAxis.Width, token); + + var heightScore = FillScore.Compute(heightResult.Parts, box); + var widthScore = FillScore.Compute(widthResult.Parts, box); + + return widthScore > heightScore ? widthResult.Parts : heightResult.Parts; + }; + + var placed = filler.FillItems(workItems, shrinkWrapper, token); + + // Build leftovers: compare placed count to original quantities. + // RemnantFiller.FillItems does NOT mutate NestItem.Quantity. + var leftovers = new List(); + foreach (var item in items) + { + var placedCount = 0; + foreach (var p in placed) + { + if (p.BaseDrawing.Name == item.Drawing.Name) + placedCount++; + } + + if (item.Quantity <= 0) + continue; // unlimited items are always "satisfied" — no leftover + + var remaining = item.Quantity - placedCount; + if (remaining > 0) + { + leftovers.Add(new NestItem + { + Drawing = item.Drawing, + Quantity = remaining, + Priority = item.Priority, + StepAngle = item.StepAngle, + RotationStart = item.RotationStart, + RotationEnd = item.RotationEnd + }); + } + } + + return new IterativeShrinkResult { Parts = placed, Leftovers = leftovers }; + } + } +} diff --git a/OpenNest.Tests/IterativeShrinkFillerTests.cs b/OpenNest.Tests/IterativeShrinkFillerTests.cs new file mode 100644 index 0000000..cf26bbb --- /dev/null +++ b/OpenNest.Tests/IterativeShrinkFillerTests.cs @@ -0,0 +1,59 @@ +using OpenNest.Engine.Fill; +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class IterativeShrinkFillerTests +{ + [Fact] + public void Fill_NullItems_ReturnsEmpty() + { + Func> fillFunc = (ni, b) => new List(); + var result = IterativeShrinkFiller.Fill(null, new Box(0, 0, 100, 100), fillFunc, 1.0); + + Assert.Empty(result.Parts); + Assert.Empty(result.Leftovers); + } + + [Fact] + public void Fill_EmptyItems_ReturnsEmpty() + { + Func> fillFunc = (ni, b) => new List(); + var result = IterativeShrinkFiller.Fill(new List(), new Box(0, 0, 100, 100), fillFunc, 1.0); + + Assert.Empty(result.Parts); + Assert.Empty(result.Leftovers); + } + + private static Drawing MakeRectDrawing(double w, double h, string name = "rect") + { + 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(name, pgm); + } + + [Fact] + public void Fill_SingleItem_PlacesParts() + { + var drawing = MakeRectDrawing(20, 10); + var items = new List + { + new NestItem { Drawing = drawing, Quantity = 5 } + }; + + Func> fillFunc = (ni, b) => + { + var plate = new Plate(b.Width, b.Length); + var engine = new DefaultNestEngine(plate); + return engine.Fill(ni, b, null, System.Threading.CancellationToken.None); + }; + + var result = IterativeShrinkFiller.Fill(items, new Box(0, 0, 120, 60), fillFunc, 1.0); + + Assert.True(result.Parts.Count > 0, "Should place parts"); + } +}