From 094b1e9f00550740ad7cf17a8ed00f84ced34cc4 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 16 Mar 2026 22:28:24 -0400 Subject: [PATCH] refactor(engine): extract ShrinkFiller from StripNestEngine Co-Authored-By: Claude Opus 4.6 (1M context) --- OpenNest.Engine/ShrinkFiller.cs | 75 ++++++++++++++++++++++ OpenNest.Tests/ShrinkFillerTests.cs | 99 +++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 OpenNest.Engine/ShrinkFiller.cs create mode 100644 OpenNest.Tests/ShrinkFillerTests.cs diff --git a/OpenNest.Engine/ShrinkFiller.cs b/OpenNest.Engine/ShrinkFiller.cs new file mode 100644 index 0000000..3b62085 --- /dev/null +++ b/OpenNest.Engine/ShrinkFiller.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; + +namespace OpenNest +{ + public enum ShrinkAxis { Width, Height } + + public class ShrinkResult + { + public List Parts { get; set; } + public double Dimension { get; set; } + } + + /// + /// Fills a box then iteratively shrinks one axis by the spacing amount + /// until the part count drops. Returns the tightest box that still fits + /// the same number of parts. + /// + public static class ShrinkFiller + { + public static ShrinkResult Shrink( + Func> fillFunc, + NestItem item, Box box, + double spacing, + ShrinkAxis axis, + CancellationToken token = default, + int maxIterations = 20) + { + var parts = fillFunc(item, box); + + if (parts == null || parts.Count == 0) + return new ShrinkResult { Parts = parts ?? new List(), Dimension = 0 }; + + var targetCount = parts.Count; + var bestParts = parts; + var bestDim = MeasureDimension(parts, box, axis); + + for (var i = 0; i < maxIterations; i++) + { + if (token.IsCancellationRequested) + break; + + var trialDim = bestDim - spacing; + if (trialDim <= 0) + break; + + var trialBox = axis == ShrinkAxis.Width + ? new Box(box.X, box.Y, trialDim, box.Length) + : new Box(box.X, box.Y, box.Width, trialDim); + + var trialParts = fillFunc(item, trialBox); + + if (trialParts == null || trialParts.Count < targetCount) + break; + + bestParts = trialParts; + bestDim = MeasureDimension(trialParts, box, axis); + } + + return new ShrinkResult { Parts = bestParts, Dimension = bestDim }; + } + + private static double MeasureDimension(List parts, Box box, ShrinkAxis axis) + { + var placedBox = parts.Cast().GetBoundingBox(); + + return axis == ShrinkAxis.Width + ? placedBox.Right - box.X + : placedBox.Top - box.Y; + } + } +} diff --git a/OpenNest.Tests/ShrinkFillerTests.cs b/OpenNest.Tests/ShrinkFillerTests.cs new file mode 100644 index 0000000..5368d51 --- /dev/null +++ b/OpenNest.Tests/ShrinkFillerTests.cs @@ -0,0 +1,99 @@ +using OpenNest.Geometry; + +namespace OpenNest.Tests; + +public class ShrinkFillerTests +{ + private static Drawing MakeSquareDrawing(double size) + { + 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(size, 0))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(size, size))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, size))); + pgm.Codes.Add(new OpenNest.CNC.LinearMove(new Vector(0, 0))); + return new Drawing("square", pgm); + } + + [Fact] + public void Shrink_ReducesDimension_UntilCountDrops() + { + var drawing = MakeSquareDrawing(10); + var item = new NestItem { Drawing = drawing }; + var box = new Box(0, 0, 100, 50); + + 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 = ShrinkFiller.Shrink(fillFunc, item, box, 1.0, ShrinkAxis.Height); + + Assert.NotNull(result); + Assert.True(result.Parts.Count > 0); + Assert.True(result.Dimension <= 50, "Dimension should be <= original"); + Assert.True(result.Dimension > 0); + } + + [Fact] + public void Shrink_Width_ReducesHorizontally() + { + var drawing = MakeSquareDrawing(10); + var item = new NestItem { Drawing = drawing }; + var box = new Box(0, 0, 100, 50); + + 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 = ShrinkFiller.Shrink(fillFunc, item, box, 1.0, ShrinkAxis.Width); + + Assert.NotNull(result); + Assert.True(result.Parts.Count > 0); + Assert.True(result.Dimension <= 100); + } + + [Fact] + public void Shrink_RespectsMaxIterations() + { + var callCount = 0; + Func> fillFunc = (ni, b) => + { + callCount++; + return new List { TestHelpers.MakePartAt(0, 0, 5) }; + }; + + var item = new NestItem { Drawing = MakeSquareDrawing(5) }; + var box = new Box(0, 0, 100, 100); + + ShrinkFiller.Shrink(fillFunc, item, box, 1.0, ShrinkAxis.Height, maxIterations: 3); + + // 1 initial + up to 3 shrink iterations = max 4 calls + Assert.True(callCount <= 4); + } + + [Fact] + public void Shrink_RespectsCancellation() + { + var cts = new System.Threading.CancellationTokenSource(); + cts.Cancel(); + + var drawing = MakeSquareDrawing(10); + var item = new NestItem { Drawing = drawing }; + var box = new Box(0, 0, 100, 50); + + Func> fillFunc = (ni, b) => + new List { TestHelpers.MakePartAt(0, 0, 10) }; + + var result = ShrinkFiller.Shrink(fillFunc, item, box, 1.0, + ShrinkAxis.Height, token: cts.Token); + + Assert.NotNull(result); + Assert.True(result.Parts.Count > 0); + } +}