diff --git a/OpenNest.Console/Program.cs b/OpenNest.Console/Program.cs
index 3bb08f0..c2d3d0d 100644
--- a/OpenNest.Console/Program.cs
+++ b/OpenNest.Console/Program.cs
@@ -121,6 +121,9 @@ static class NestConsole
case "--autonest":
o.AutoNest = true;
break;
+ case "--engine" when i + 1 < args.Length:
+ NestEngineRegistry.ActiveEngineName = args[++i];
+ break;
case "--help":
case "-h":
PrintUsage();
diff --git a/OpenNest.Core/Geometry/PolyLabel.cs b/OpenNest.Core/Geometry/PolyLabel.cs
index 8e5b197..9f3af4b 100644
--- a/OpenNest.Core/Geometry/PolyLabel.cs
+++ b/OpenNest.Core/Geometry/PolyLabel.cs
@@ -3,25 +3,23 @@ using System.Collections.Generic;
namespace OpenNest.Geometry
{
- ///
- /// Finds the pole of inaccessibility — the point inside a polygon that is
- /// farthest from any edge. Based on the polylabel algorithm by Mapbox.
- ///
public static class PolyLabel
{
- public static Vector Find(List vertices, double precision = 1.0)
+ public static Vector Find(Polygon outer, IList holes = null, double precision = 0.5)
{
- if (vertices == null || vertices.Count < 3)
- return Vector.Zero;
+ if (outer.Vertices.Count < 3)
+ return outer.Vertices.Count > 0
+ ? outer.Vertices[0]
+ : new Vector();
var minX = double.MaxValue;
var minY = double.MaxValue;
var maxX = double.MinValue;
var maxY = double.MinValue;
- for (var i = 0; i < vertices.Count; i++)
+ for (var i = 0; i < outer.Vertices.Count; i++)
{
- var v = vertices[i];
+ var v = outer.Vertices[i];
if (v.X < minX) minX = v.X;
if (v.Y < minY) minY = v.Y;
if (v.X > maxX) maxX = v.X;
@@ -33,162 +31,185 @@ namespace OpenNest.Geometry
var cellSize = System.Math.Min(width, height);
if (cellSize == 0)
- return new Vector(minX, minY);
+ return new Vector((minX + maxX) / 2, (minY + maxY) / 2);
- var halfCell = cellSize / 2.0;
+ var halfCell = cellSize / 2;
- // Priority queue (sorted list, largest distance first)
var queue = new List();
for (var x = minX; x < maxX; x += cellSize)
- {
for (var y = minY; y < maxY; y += cellSize)
+ queue.Add(new Cell(x + halfCell, y + halfCell, halfCell, outer, holes));
+
+ queue.Sort((a, b) => b.MaxDist.CompareTo(a.MaxDist));
+
+ var bestCell = GetCentroidCell(outer, holes);
+
+ for (var i = 0; i < queue.Count; i++)
+ if (queue[i].Dist > bestCell.Dist)
{
- queue.Add(new Cell(x + halfCell, y + halfCell, halfCell, vertices));
+ bestCell = queue[i];
+ break;
}
- }
-
- queue.Sort((a, b) => b.Max.CompareTo(a.Max));
-
- var bestCell = GetCentroidCell(vertices);
-
- var bboxCell = new Cell(minX + width / 2, minY + height / 2, 0, vertices);
- if (bboxCell.Distance > bestCell.Distance)
- bestCell = bboxCell;
while (queue.Count > 0)
{
- var cell = queue[queue.Count - 1];
- queue.RemoveAt(queue.Count - 1);
+ var cell = queue[0];
+ queue.RemoveAt(0);
- if (cell.Distance > bestCell.Distance)
+ if (cell.Dist > bestCell.Dist)
bestCell = cell;
- if (cell.Max - bestCell.Distance <= precision)
+ if (cell.MaxDist - bestCell.Dist <= precision)
continue;
halfCell = cell.HalfSize / 2;
- var c1 = new Cell(cell.X - halfCell, cell.Y - halfCell, halfCell, vertices);
- var c2 = new Cell(cell.X + halfCell, cell.Y - halfCell, halfCell, vertices);
- var c3 = new Cell(cell.X - halfCell, cell.Y + halfCell, halfCell, vertices);
- var c4 = new Cell(cell.X + halfCell, cell.Y + halfCell, halfCell, vertices);
+ var newCells = new[]
+ {
+ new Cell(cell.X - halfCell, cell.Y - halfCell, halfCell, outer, holes),
+ new Cell(cell.X + halfCell, cell.Y - halfCell, halfCell, outer, holes),
+ new Cell(cell.X - halfCell, cell.Y + halfCell, halfCell, outer, holes),
+ new Cell(cell.X + halfCell, cell.Y + halfCell, halfCell, outer, holes),
+ };
- InsertSorted(queue, c1);
- InsertSorted(queue, c2);
- InsertSorted(queue, c3);
- InsertSorted(queue, c4);
+ for (var i = 0; i < newCells.Length; i++)
+ {
+ if (newCells[i].MaxDist > bestCell.Dist + precision)
+ InsertSorted(queue, newCells[i]);
+ }
}
return new Vector(bestCell.X, bestCell.Y);
}
- private static void InsertSorted(List queue, Cell cell)
+ private static void InsertSorted(List list, Cell cell)
{
- var index = queue.BinarySearch(cell, CellComparer.Instance);
- if (index < 0) index = ~index;
- queue.Insert(index, cell);
+ var idx = 0;
+ while (idx < list.Count && list[idx].MaxDist > cell.MaxDist)
+ idx++;
+ list.Insert(idx, cell);
}
- private static Cell GetCentroidCell(List vertices)
+ private static Cell GetCentroidCell(Polygon outer, IList holes)
{
var area = 0.0;
var cx = 0.0;
var cy = 0.0;
- var n = vertices.Count;
+ var verts = outer.Vertices;
- for (int i = 0, j = n - 1; i < n; j = i++)
+ for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++)
{
- var a = vertices[i];
- var b = vertices[j];
- var f = a.X * b.Y - b.X * a.Y;
- cx += (a.X + b.X) * f;
- cy += (a.Y + b.Y) * f;
- area += f * 3;
+ var a = verts[i];
+ var b = verts[j];
+ var cross = a.X * b.Y - b.X * a.Y;
+ cx += (a.X + b.X) * cross;
+ cy += (a.Y + b.Y) * cross;
+ area += cross;
}
- if (area == 0)
- return new Cell(vertices[0].X, vertices[0].Y, 0, vertices);
+ area *= 0.5;
- return new Cell(cx / area, cy / area, 0, vertices);
+ if (System.Math.Abs(area) < 1e-10)
+ return new Cell(verts[0].X, verts[0].Y, 0, outer, holes);
+
+ cx /= (6 * area);
+ cy /= (6 * area);
+
+ return new Cell(cx, cy, 0, outer, holes);
}
- private static double PointToPolygonDistance(double x, double y, List vertices)
+ private static double PointToPolygonDist(double x, double y, Polygon polygon)
{
- var inside = false;
- var minDistSq = double.MaxValue;
- var n = vertices.Count;
+ var minDist = double.MaxValue;
+ var verts = polygon.Vertices;
- for (int i = 0, j = n - 1; i < n; j = i++)
+ for (int i = 0, j = verts.Count - 1; i < verts.Count; j = i++)
{
- var a = vertices[i];
- var b = vertices[j];
+ var a = verts[i];
+ var b = verts[j];
- if ((a.Y > y) != (b.Y > y) &&
- x < (b.X - a.X) * (y - a.Y) / (b.Y - a.Y) + a.X)
+ var dx = b.X - a.X;
+ var dy = b.Y - a.Y;
+
+ if (dx != 0 || dy != 0)
{
- inside = !inside;
+ var t = ((x - a.X) * dx + (y - a.Y) * dy) / (dx * dx + dy * dy);
+
+ if (t > 1)
+ {
+ a = b;
+ }
+ else if (t > 0)
+ {
+ a = new Vector(a.X + dx * t, a.Y + dy * t);
+ }
}
- var distSq = SegmentDistanceSq(x, y, a.X, a.Y, b.X, b.Y);
- if (distSq < minDistSq)
- minDistSq = distSq;
+ var segDx = x - a.X;
+ var segDy = y - a.Y;
+ var dist = System.Math.Sqrt(segDx * segDx + segDy * segDy);
+
+ if (dist < minDist)
+ minDist = dist;
}
- var dist = System.Math.Sqrt(minDistSq);
- return inside ? dist : -dist;
+ return minDist;
}
- private static double SegmentDistanceSq(double px, double py,
- double ax, double ay, double bx, double by)
- {
- var dx = bx - ax;
- var dy = by - ay;
-
- if (dx != 0 || dy != 0)
- {
- var t = ((px - ax) * dx + (py - ay) * dy) / (dx * dx + dy * dy);
-
- if (t > 1)
- {
- ax = bx;
- ay = by;
- }
- else if (t > 0)
- {
- ax += dx * t;
- ay += dy * t;
- }
- }
-
- dx = px - ax;
- dy = py - ay;
-
- return dx * dx + dy * dy;
- }
-
- private struct Cell
+ private sealed class Cell
{
public readonly double X;
public readonly double Y;
public readonly double HalfSize;
- public readonly double Distance;
- public readonly double Max;
+ public readonly double Dist;
+ public readonly double MaxDist;
- public Cell(double x, double y, double halfSize, List vertices)
+ public Cell(double x, double y, double halfSize, Polygon outer, IList holes)
{
X = x;
Y = y;
HalfSize = halfSize;
- Distance = PointToPolygonDistance(x, y, vertices);
- Max = Distance + halfSize * System.Math.Sqrt(2);
+
+ var pt = new Vector(x, y);
+ var inside = outer.ContainsPoint(pt);
+
+ if (inside && holes != null)
+ {
+ for (var i = 0; i < holes.Count; i++)
+ {
+ if (holes[i].ContainsPoint(pt))
+ {
+ inside = false;
+ break;
+ }
+ }
+ }
+
+ Dist = PointToAllEdgesDist(x, y, outer, holes);
+
+ if (!inside)
+ Dist = -Dist;
+
+ MaxDist = Dist + HalfSize * System.Math.Sqrt(2);
}
}
- private class CellComparer : IComparer|
+ private static double PointToAllEdgesDist(double x, double y, Polygon outer, IList holes)
{
- public static readonly CellComparer Instance = new CellComparer();
- public int Compare(Cell a, Cell b) => b.Max.CompareTo(a.Max);
+ var minDist = PointToPolygonDist(x, y, outer);
+
+ if (holes != null)
+ {
+ for (var i = 0; i < holes.Count; i++)
+ {
+ var d = PointToPolygonDist(x, y, holes[i]);
+ if (d < minDist)
+ minDist = d;
+ }
+ }
+
+ return minDist;
}
}
}
diff --git a/OpenNest.Engine/AccumulatingProgress.cs b/OpenNest.Engine/AccumulatingProgress.cs
new file mode 100644
index 0000000..7b84803
--- /dev/null
+++ b/OpenNest.Engine/AccumulatingProgress.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+
+namespace OpenNest
+{
+ ///
+ /// Wraps an IProgress to prepend previously placed parts to each report,
+ /// so the UI shows the full picture during incremental fills.
+ ///
+ internal class AccumulatingProgress : IProgress
+ {
+ private readonly IProgress inner;
+ private readonly List previousParts;
+
+ public AccumulatingProgress(IProgress inner, List previousParts)
+ {
+ this.inner = inner;
+ this.previousParts = previousParts;
+ }
+
+ public void Report(NestProgress value)
+ {
+ if (value.BestParts != null && previousParts.Count > 0)
+ {
+ var combined = new List(previousParts.Count + value.BestParts.Count);
+ combined.AddRange(previousParts);
+ combined.AddRange(value.BestParts);
+ value.BestParts = combined;
+ value.BestPartCount = combined.Count;
+ }
+
+ inner.Report(value);
+ }
+ }
+}
diff --git a/OpenNest.Engine/AngleCandidateBuilder.cs b/OpenNest.Engine/AngleCandidateBuilder.cs
new file mode 100644
index 0000000..8c09ce0
--- /dev/null
+++ b/OpenNest.Engine/AngleCandidateBuilder.cs
@@ -0,0 +1,97 @@
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using OpenNest.Engine.ML;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest
+{
+ ///
+ /// Builds candidate rotation angles for single-item fill. Encapsulates the
+ /// full pipeline: base angles, narrow-area sweep, ML prediction, and
+ /// known-good pruning across fills.
+ ///
+ public class AngleCandidateBuilder
+ {
+ private readonly HashSet knownGoodAngles = new();
+
+ public bool ForceFullSweep { get; set; }
+
+ public List Build(NestItem item, double bestRotation, Box workArea)
+ {
+ var angles = new List { bestRotation, bestRotation + Angle.HalfPI };
+
+ var testPart = new Part(item.Drawing);
+ if (!bestRotation.IsEqualTo(0))
+ testPart.Rotate(bestRotation);
+ testPart.UpdateBounds();
+
+ var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Length);
+ var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length);
+ var needsSweep = workAreaShortSide < partLongestSide || ForceFullSweep;
+
+ if (needsSweep)
+ {
+ var step = Angle.ToRadians(5);
+ for (var a = 0.0; a < System.Math.PI; a += step)
+ {
+ if (!angles.Any(existing => existing.IsEqualTo(a)))
+ angles.Add(a);
+ }
+ }
+
+ if (!ForceFullSweep && angles.Count > 2)
+ {
+ var features = FeatureExtractor.Extract(item.Drawing);
+ if (features != null)
+ {
+ var predicted = AnglePredictor.PredictAngles(
+ features, workArea.Width, workArea.Length);
+
+ if (predicted != null)
+ {
+ var mlAngles = new List(predicted);
+
+ if (!mlAngles.Any(a => a.IsEqualTo(bestRotation)))
+ mlAngles.Add(bestRotation);
+ if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI)))
+ mlAngles.Add(bestRotation + Angle.HalfPI);
+
+ Debug.WriteLine($"[AngleCandidateBuilder] ML: {angles.Count} angles -> {mlAngles.Count} predicted");
+ angles = mlAngles;
+ }
+ }
+ }
+
+ if (knownGoodAngles.Count > 0 && !ForceFullSweep)
+ {
+ var pruned = new List { bestRotation, bestRotation + Angle.HalfPI };
+
+ foreach (var a in knownGoodAngles)
+ {
+ if (!pruned.Any(existing => existing.IsEqualTo(a)))
+ pruned.Add(a);
+ }
+
+ Debug.WriteLine($"[AngleCandidateBuilder] Pruned: {angles.Count} -> {pruned.Count} angles (known-good)");
+ return pruned;
+ }
+
+ return angles;
+ }
+
+ ///
+ /// Records angles that produced results. These are used to prune
+ /// subsequent Build() calls.
+ ///
+ public void RecordProductive(List angleResults)
+ {
+ foreach (var ar in angleResults)
+ {
+ if (ar.PartCount > 0)
+ knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg));
+ }
+ }
+ }
+}
diff --git a/OpenNest.Engine/DefaultNestEngine.cs b/OpenNest.Engine/DefaultNestEngine.cs
index 43ca4c0..8cb765e 100644
--- a/OpenNest.Engine/DefaultNestEngine.cs
+++ b/OpenNest.Engine/DefaultNestEngine.cs
@@ -5,7 +5,6 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.Engine.BestFit;
-using OpenNest.Engine.ML;
using OpenNest.Geometry;
using OpenNest.Math;
using OpenNest.RectanglePacking;
@@ -20,11 +19,13 @@ namespace OpenNest
public override string Description => "Multi-phase nesting (Linear, Pairs, RectBestFit)";
- public bool ForceFullAngleSweep { get; set; }
+ private readonly AngleCandidateBuilder angleBuilder = new();
- // Angles that have produced results across multiple Fill calls.
- // Populated after each Fill; used to prune subsequent fills.
- private readonly HashSet knownGoodAngles = new();
+ public bool ForceFullAngleSweep
+ {
+ get => angleBuilder.ForceFullSweep;
+ set => angleBuilder.ForceFullSweep = value;
+ }
// --- Public Fill API ---
@@ -134,7 +135,8 @@ namespace OpenNest
token.ThrowIfCancellationRequested();
- var pairResult = FillWithPairs(nestItem, workArea, token, progress);
+ var pairFiller = new PairFiller(Plate.Size, Plate.PartSpacing);
+ var pairResult = pairFiller.Fill(nestItem, workArea, PlateNumber, token, progress);
PhaseResults.Add(new PhaseResult(NestPhase.Pairs, pairResult.Count, 0));
Debug.WriteLine($"[Fill(groupParts,Box)] Pair: {pairResult.Count} parts | Winner: {(IsBetterFill(pairResult, best, workArea) ? "Pair" : "Linear")}");
@@ -178,11 +180,12 @@ namespace OpenNest
try
{
var bestRotation = RotationAnalysis.FindBestRotation(item);
- var angles = BuildCandidateAngles(item, bestRotation, workArea);
+ var angles = angleBuilder.Build(item, bestRotation, workArea);
// Pairs phase
var pairSw = Stopwatch.StartNew();
- var pairResult = FillWithPairs(item, workArea, token, progress);
+ var pairFiller = new PairFiller(Plate.Size, Plate.PartSpacing);
+ var pairResult = pairFiller.Fill(item, workArea, PlateNumber, token, progress);
pairSw.Stop();
best = pairResult;
var bestScore = FillScore.Compute(best, workArea);
@@ -239,12 +242,7 @@ namespace OpenNest
linearSw.Stop();
PhaseResults.Add(new PhaseResult(NestPhase.Linear, bestLinearCount, linearSw.ElapsedMilliseconds));
- // Record productive angles for future fills.
- foreach (var ar in AngleResults)
- {
- if (ar.PartCount > 0)
- knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg));
- }
+ angleBuilder.RecordProductive(AngleResults);
Debug.WriteLine($"[FindBestFill] Linear: {bestScore.Count} parts, density={bestScore.Density:P1} | WorkArea: {workArea.Width:F1}x{workArea.Length:F1} | Angles: {angles.Count}");
@@ -274,78 +272,6 @@ namespace OpenNest
return best ?? new List();
}
- // --- Angle building ---
-
- private List BuildCandidateAngles(NestItem item, double bestRotation, Box workArea)
- {
- var angles = new List { bestRotation, bestRotation + Angle.HalfPI };
-
- // When the work area is narrow relative to the part, sweep rotation
- // angles so we can find one that fits the part into the tight strip.
- var testPart = new Part(item.Drawing);
- if (!bestRotation.IsEqualTo(0))
- testPart.Rotate(bestRotation);
- testPart.UpdateBounds();
-
- var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Length);
- var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length);
- var needsSweep = workAreaShortSide < partLongestSide || ForceFullAngleSweep;
-
- if (needsSweep)
- {
- var step = Angle.ToRadians(5);
- for (var a = 0.0; a < System.Math.PI; a += step)
- {
- if (!angles.Any(existing => existing.IsEqualTo(a)))
- angles.Add(a);
- }
- }
-
- // When the work area triggers a full sweep (and we're not forcing it for training),
- // try ML angle prediction to reduce the sweep.
- if (!ForceFullAngleSweep && angles.Count > 2)
- {
- var features = FeatureExtractor.Extract(item.Drawing);
- if (features != null)
- {
- var predicted = AnglePredictor.PredictAngles(
- features, workArea.Width, workArea.Length);
-
- if (predicted != null)
- {
- var mlAngles = new List(predicted);
-
- if (!mlAngles.Any(a => a.IsEqualTo(bestRotation)))
- mlAngles.Add(bestRotation);
- if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI)))
- mlAngles.Add(bestRotation + Angle.HalfPI);
-
- Debug.WriteLine($"[BuildCandidateAngles] ML: {angles.Count} angles -> {mlAngles.Count} predicted");
- angles = mlAngles;
- }
- }
- }
-
- // If we have known-good angles from previous fills, use only those
- // plus the defaults (bestRotation + 90°). This prunes the expensive
- // angle sweep after the first fill.
- if (knownGoodAngles.Count > 0 && !ForceFullAngleSweep)
- {
- var pruned = new List { bestRotation, bestRotation + Angle.HalfPI };
-
- foreach (var a in knownGoodAngles)
- {
- if (!pruned.Any(existing => existing.IsEqualTo(a)))
- pruned.Add(a);
- }
-
- Debug.WriteLine($"[BuildCandidateAngles] Pruned: {angles.Count} -> {pruned.Count} angles (known-good)");
- return pruned;
- }
-
- return angles;
- }
-
// --- Fill strategies ---
private List FillRectangleBestFit(NestItem item, Box workArea)
@@ -359,123 +285,9 @@ namespace OpenNest
return BinConverter.ToParts(bin, new List { item });
}
- private List FillWithPairs(NestItem item, Box workArea,
- CancellationToken token = default, IProgress progress = null)
- {
- var bestFits = BestFitCache.GetOrCompute(
- item.Drawing, Plate.Size.Width, Plate.Size.Length,
- Plate.PartSpacing);
-
- var candidates = SelectPairCandidates(bestFits, workArea);
- var diagMsg = $"[FillWithPairs] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}\n" +
- $"[FillWithPairs] Plate: {Plate.Size.Width:F2}x{Plate.Size.Length:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}";
- Debug.WriteLine(diagMsg);
- try { System.IO.File.AppendAllText(
- System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
- $"{DateTime.Now:HH:mm:ss} {diagMsg}\n"); } catch { }
-
- List best = null;
- var bestScore = default(FillScore);
- var sinceImproved = 0;
-
- try
- {
- for (var i = 0; i < candidates.Count; i++)
- {
- token.ThrowIfCancellationRequested();
-
- var result = candidates[i];
- var pairParts = result.BuildParts(item.Drawing);
- var angles = result.HullAngles;
- var engine = new FillLinear(workArea, Plate.PartSpacing);
- var filled = FillPattern(engine, pairParts, angles, workArea);
-
- if (filled != null && filled.Count > 0)
- {
- var score = FillScore.Compute(filled, workArea);
- if (best == null || score > bestScore)
- {
- best = filled;
- bestScore = score;
- sinceImproved = 0;
- }
- else
- {
- sinceImproved++;
- }
- }
- else
- {
- sinceImproved++;
- }
-
- ReportProgress(progress, NestPhase.Pairs, PlateNumber, best, workArea,
- $"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts");
-
- // Early exit: stop if we've tried enough candidates without improvement.
- if (i >= 9 && sinceImproved >= 10)
- {
- Debug.WriteLine($"[FillWithPairs] Early exit at {i + 1}/{candidates.Count} — no improvement in last {sinceImproved} candidates");
- break;
- }
- }
- }
- catch (OperationCanceledException)
- {
- Debug.WriteLine("[FillWithPairs] Cancelled mid-phase, using results so far");
- }
-
- Debug.WriteLine($"[FillWithPairs] Best pair result: {bestScore.Count} parts, density={bestScore.Density:P1}");
- try { System.IO.File.AppendAllText(
- System.IO.Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "nest-debug.log"),
- $"{DateTime.Now:HH:mm:ss} [FillWithPairs] Best: {bestScore.Count} parts, density={bestScore.Density:P1}\n"); } catch { }
- return best ?? new List();
- }
-
- ///
- /// Selects pair candidates to try for the given work area. Always includes
- /// the top 50 by area. For narrow work areas, also includes all pairs whose
- /// shortest side fits the strip width.
- ///
- private List SelectPairCandidates(List bestFits, Box workArea)
- {
- var kept = bestFits.Where(r => r.Keep).ToList();
- var top = kept.Take(50).ToList();
-
- var workShortSide = System.Math.Min(workArea.Width, workArea.Length);
- var plateShortSide = System.Math.Min(Plate.Size.Width, Plate.Size.Length);
-
- // When the work area is significantly narrower than the plate,
- // search ALL candidates (not just kept) for pairs that fit the
- // narrow dimension. Pairs rejected by aspect ratio for the full
- // plate may be exactly what's needed for a narrow remainder strip.
- if (workShortSide < plateShortSide * 0.5)
- {
- var stripCandidates = bestFits
- .Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon
- && r.Utilization >= 0.3)
- .OrderByDescending(r => r.Utilization);
-
- var existing = new HashSet(top);
-
- foreach (var r in stripCandidates)
- {
- if (top.Count >= 100)
- break;
-
- if (existing.Add(r))
- top.Add(r);
- }
-
- Debug.WriteLine($"[SelectPairCandidates] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})");
- }
-
- return top;
- }
-
// --- Pattern helpers ---
- private Pattern BuildRotatedPattern(List groupParts, double angle)
+ internal static Pattern BuildRotatedPattern(List groupParts, double angle)
{
var pattern = new Pattern();
var center = ((IEnumerable)groupParts).GetBoundingBox().Center;
@@ -495,7 +307,7 @@ namespace OpenNest
return pattern;
}
- private List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea)
+ internal static List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea)
{
var results = new System.Collections.Concurrent.ConcurrentBag<(List Parts, FillScore Score)>();
diff --git a/OpenNest.Engine/FillLinear.cs b/OpenNest.Engine/FillLinear.cs
index 92e6415..8fe1ef0 100644
--- a/OpenNest.Engine/FillLinear.cs
+++ b/OpenNest.Engine/FillLinear.cs
@@ -19,6 +19,11 @@ namespace OpenNest
public double HalfSpacing => PartSpacing / 2;
+ ///
+ /// Optional multi-part patterns (e.g. interlocking pairs) to try in remainder strips.
+ ///
+ public List RemainderPatterns { get; set; }
+
private static Vector MakeOffset(NestDirection direction, double distance)
{
return direction == NestDirection.Horizontal
@@ -408,7 +413,30 @@ namespace OpenNest
return new List();
var rotations = BuildRotationSet(seedPattern);
- return FindBestFill(rotations, remainingStrip);
+ var best = FindBestFill(rotations, remainingStrip);
+
+ if (RemainderPatterns != null)
+ {
+ System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Strip: {remainingStrip.Width:F1}x{remainingStrip.Length:F1}, individual best={best?.Count ?? 0}, trying {RemainderPatterns.Count} patterns");
+
+ foreach (var pattern in RemainderPatterns)
+ {
+ var filler = new FillLinear(remainingStrip, PartSpacing);
+ var h = filler.Fill(pattern, NestDirection.Horizontal);
+ var v = filler.Fill(pattern, NestDirection.Vertical);
+
+ System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Pattern ({pattern.Parts.Count} parts, bbox={pattern.BoundingBox.Width:F1}x{pattern.BoundingBox.Length:F1}): H={h?.Count ?? 0}, V={v?.Count ?? 0}");
+
+ if (h != null && h.Count > (best?.Count ?? 0))
+ best = h;
+ if (v != null && v.Count > (best?.Count ?? 0))
+ best = v;
+ }
+
+ System.Diagnostics.Debug.WriteLine($"[FillRemainingStrip] Final best={best?.Count ?? 0}");
+ }
+
+ return best ?? new List();
}
private static double FindPlacedEdge(List placedParts, NestDirection tiledAxis)
diff --git a/OpenNest.Engine/NestEngineBase.cs b/OpenNest.Engine/NestEngineBase.cs
index 29c8eb6..681eacb 100644
--- a/OpenNest.Engine/NestEngineBase.cs
+++ b/OpenNest.Engine/NestEngineBase.cs
@@ -71,29 +71,36 @@ namespace OpenNest
.Where(i => i.Quantity == 1)
.ToList();
- // Phase 1: Fill multi-quantity drawings sequentially.
- foreach (var item in fillItems)
+ // Phase 1: Fill multi-quantity drawings using RemnantFiller.
+ if (fillItems.Count > 0)
{
- if (token.IsCancellationRequested)
- break;
+ var remnantFiller = new RemnantFiller(workArea, Plate.PartSpacing);
- if (item.Quantity <= 0 || workArea.Width <= 0 || workArea.Length <= 0)
- continue;
+ Func> fillFunc = (ni, b) =>
+ FillExact(ni, b, progress, token);
- var parts = FillExact(
- new NestItem { Drawing = item.Drawing, Quantity = item.Quantity },
- workArea, progress, token);
+ var fillParts = remnantFiller.FillItems(fillItems, fillFunc, token, progress);
- if (parts.Count > 0)
+ if (fillParts.Count > 0)
{
- allParts.AddRange(parts);
- item.Quantity = System.Math.Max(0, item.Quantity - parts.Count);
- var placedObstacles = parts.Select(p => p.BoundingBox.Offset(Plate.PartSpacing)).ToList();
+ allParts.AddRange(fillParts);
+
+ // Deduct placed quantities
+ foreach (var item in fillItems)
+ {
+ var placed = fillParts.Count(p =>
+ p.BaseDrawing.Name == item.Drawing.Name);
+ item.Quantity = System.Math.Max(0, item.Quantity - placed);
+ }
+
+ // Update workArea for pack phase
+ var placedObstacles = fillParts.Select(p => p.BoundingBox.Offset(Plate.PartSpacing)).ToList();
var finder = new RemnantFinder(workArea, placedObstacles);
var remnants = finder.FindRemnants();
- if (remnants.Count == 0)
- break;
- workArea = remnants[0]; // Largest remnant
+ if (remnants.Count > 0)
+ workArea = remnants[0];
+ else
+ workArea = new Box(0, 0, 0, 0);
}
}
@@ -177,7 +184,7 @@ namespace OpenNest
// --- Protected utilities ---
- protected static void ReportProgress(
+ internal static void ReportProgress(
IProgress progress,
NestPhase phase,
int plateNumber,
diff --git a/OpenNest.Engine/OpenNest.Engine.csproj b/OpenNest.Engine/OpenNest.Engine.csproj
index bcf6d9e..cda0c99 100644
--- a/OpenNest.Engine/OpenNest.Engine.csproj
+++ b/OpenNest.Engine/OpenNest.Engine.csproj
@@ -4,6 +4,9 @@
OpenNest
OpenNest.Engine
+
+
+
diff --git a/OpenNest.Engine/PairFiller.cs b/OpenNest.Engine/PairFiller.cs
new file mode 100644
index 0000000..b448c96
--- /dev/null
+++ b/OpenNest.Engine/PairFiller.cs
@@ -0,0 +1,126 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using OpenNest.Engine.BestFit;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest
+{
+ ///
+ /// Fills a work area using interlocking part pairs from BestFitCache.
+ /// Extracted from DefaultNestEngine.FillWithPairs.
+ ///
+ public class PairFiller
+ {
+ private readonly Size plateSize;
+ private readonly double partSpacing;
+
+ public PairFiller(Size plateSize, double partSpacing)
+ {
+ this.plateSize = plateSize;
+ this.partSpacing = partSpacing;
+ }
+
+ public List Fill(NestItem item, Box workArea,
+ int plateNumber = 0,
+ CancellationToken token = default,
+ IProgress progress = null)
+ {
+ var bestFits = BestFitCache.GetOrCompute(
+ item.Drawing, plateSize.Width, plateSize.Length, partSpacing);
+
+ var candidates = SelectPairCandidates(bestFits, workArea);
+ Debug.WriteLine($"[PairFiller] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}");
+ Debug.WriteLine($"[PairFiller] Plate: {plateSize.Width:F2}x{plateSize.Length:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}");
+
+ List best = null;
+ var bestScore = default(FillScore);
+ var sinceImproved = 0;
+
+ try
+ {
+ for (var i = 0; i < candidates.Count; i++)
+ {
+ token.ThrowIfCancellationRequested();
+
+ var result = candidates[i];
+ var pairParts = result.BuildParts(item.Drawing);
+ var angles = result.HullAngles;
+ var engine = new FillLinear(workArea, partSpacing);
+ var filled = DefaultNestEngine.FillPattern(engine, pairParts, angles, workArea);
+
+ if (filled != null && filled.Count > 0)
+ {
+ var score = FillScore.Compute(filled, workArea);
+ if (best == null || score > bestScore)
+ {
+ best = filled;
+ bestScore = score;
+ sinceImproved = 0;
+ }
+ else
+ {
+ sinceImproved++;
+ }
+ }
+ else
+ {
+ sinceImproved++;
+ }
+
+ NestEngineBase.ReportProgress(progress, NestPhase.Pairs, plateNumber, best, workArea,
+ $"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts");
+
+ // Early exit: stop if we've tried enough candidates without improvement.
+ if (i >= 9 && sinceImproved >= 10)
+ {
+ Debug.WriteLine($"[PairFiller] Early exit at {i + 1}/{candidates.Count} — no improvement in last {sinceImproved} candidates");
+ break;
+ }
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ Debug.WriteLine("[PairFiller] Cancelled mid-phase, using results so far");
+ }
+
+ Debug.WriteLine($"[PairFiller] Best pair result: {bestScore.Count} parts, density={bestScore.Density:P1}");
+ return best ?? new List();
+ }
+
+ private List SelectPairCandidates(List bestFits, Box workArea)
+ {
+ var kept = bestFits.Where(r => r.Keep).ToList();
+ var top = kept.Take(50).ToList();
+
+ var workShortSide = System.Math.Min(workArea.Width, workArea.Length);
+ var plateShortSide = System.Math.Min(plateSize.Width, plateSize.Length);
+
+ if (workShortSide < plateShortSide * 0.5)
+ {
+ var stripCandidates = bestFits
+ .Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon
+ && r.Utilization >= 0.3)
+ .OrderByDescending(r => r.Utilization);
+
+ var existing = new HashSet(top);
+
+ foreach (var r in stripCandidates)
+ {
+ if (top.Count >= 100)
+ break;
+
+ if (existing.Add(r))
+ top.Add(r);
+ }
+
+ Debug.WriteLine($"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})");
+ }
+
+ return top;
+ }
+ }
+}
diff --git a/OpenNest.Engine/RemnantFiller.cs b/OpenNest.Engine/RemnantFiller.cs
new file mode 100644
index 0000000..44036d8
--- /dev/null
+++ b/OpenNest.Engine/RemnantFiller.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using OpenNest.Geometry;
+
+namespace OpenNest
+{
+ ///
+ /// Iteratively fills remnant boxes with items using a RemnantFinder.
+ /// After each fill, re-discovers free rectangles and tries again
+ /// until no more items can be placed.
+ ///
+ public class RemnantFiller
+ {
+ private readonly RemnantFinder finder;
+ private readonly double spacing;
+
+ public RemnantFiller(Box workArea, double spacing)
+ {
+ this.spacing = spacing;
+ finder = new RemnantFinder(workArea);
+ }
+
+ public void AddObstacles(IEnumerable parts)
+ {
+ foreach (var part in parts)
+ finder.AddObstacle(part.BoundingBox.Offset(spacing));
+ }
+
+ public List FillItems(
+ List items,
+ Func> fillFunc,
+ CancellationToken token = default,
+ IProgress progress = null)
+ {
+ if (items == null || items.Count == 0)
+ return new List();
+
+ var allParts = new List();
+ var madeProgress = true;
+
+ // Track quantities locally — do not mutate the input NestItem objects.
+ var localQty = new Dictionary();
+ foreach (var item in items)
+ localQty[item.Drawing.Name] = item.Quantity;
+
+ while (madeProgress && !token.IsCancellationRequested)
+ {
+ madeProgress = false;
+
+ var minRemnantDim = double.MaxValue;
+ foreach (var item in items)
+ {
+ var qty = localQty[item.Drawing.Name];
+ if (qty <= 0)
+ continue;
+ var bb = item.Drawing.Program.BoundingBox();
+ var dim = System.Math.Min(bb.Width, bb.Length);
+ if (dim < minRemnantDim)
+ minRemnantDim = dim;
+ }
+
+ if (minRemnantDim == double.MaxValue)
+ break;
+
+ var freeBoxes = finder.FindRemnants(minRemnantDim);
+
+ if (freeBoxes.Count == 0)
+ break;
+
+ foreach (var item in items)
+ {
+ if (token.IsCancellationRequested)
+ break;
+
+ var qty = localQty[item.Drawing.Name];
+ if (qty == 0)
+ continue;
+
+ var itemBbox = item.Drawing.Program.BoundingBox();
+ var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length);
+
+ foreach (var box in freeBoxes)
+ {
+ if (System.Math.Min(box.Width, box.Length) < minItemDim)
+ continue;
+
+ var fillItem = new NestItem { Drawing = item.Drawing, Quantity = qty };
+ var remnantParts = fillFunc(fillItem, box);
+
+ if (remnantParts != null && remnantParts.Count > 0)
+ {
+ allParts.AddRange(remnantParts);
+ localQty[item.Drawing.Name] = System.Math.Max(0, qty - remnantParts.Count);
+
+ foreach (var p in remnantParts)
+ finder.AddObstacle(p.BoundingBox.Offset(spacing));
+
+ madeProgress = true;
+ break;
+ }
+ }
+
+ if (madeProgress)
+ break;
+ }
+ }
+
+ return allParts;
+ }
+ }
+}
diff --git a/OpenNest.Engine/RemnantFinder.cs b/OpenNest.Engine/RemnantFinder.cs
index b5e2cef..a6ad082 100644
--- a/OpenNest.Engine/RemnantFinder.cs
+++ b/OpenNest.Engine/RemnantFinder.cs
@@ -5,12 +5,37 @@ using OpenNest.Geometry;
namespace OpenNest
{
+ ///
+ /// A remnant box with a priority tier.
+ /// 0 = within the used envelope (best), 1 = extends past one edge, 2 = fully outside.
+ ///
+ public struct TieredRemnant
+ {
+ public Box Box;
+ public int Priority;
+
+ public TieredRemnant(Box box, int priority)
+ {
+ Box = box;
+ Priority = priority;
+ }
+ }
+
public class RemnantFinder
{
private readonly Box workArea;
public List Obstacles { get; } = new();
+ private struct CellGrid
+ {
+ public bool[,] Empty;
+ public List XCoords;
+ public List YCoords;
+ public int Rows;
+ public int Cols;
+ }
+
public RemnantFinder(Box workArea, List obstacles = null)
{
this.workArea = workArea;
@@ -27,54 +52,52 @@ namespace OpenNest
public List FindRemnants(double minDimension = 0)
{
- var xs = new SortedSet { workArea.Left, workArea.Right };
- var ys = new SortedSet { workArea.Bottom, workArea.Top };
+ var grid = BuildGrid();
- foreach (var obs in Obstacles)
- {
- var clipped = ClipToWorkArea(obs);
- if (clipped.Width <= 0 || clipped.Length <= 0)
- continue;
-
- xs.Add(clipped.Left);
- xs.Add(clipped.Right);
- ys.Add(clipped.Bottom);
- ys.Add(clipped.Top);
- }
-
- var xList = xs.ToList();
- var yList = ys.ToList();
-
- var cols = xList.Count - 1;
- var rows = yList.Count - 1;
-
- if (cols <= 0 || rows <= 0)
+ if (grid.Rows <= 0 || grid.Cols <= 0)
return new List();
- var empty = new bool[rows, cols];
+ var merged = MergeCells(grid);
+ var sized = FilterBySize(merged, minDimension);
+ var unique = RemoveDominated(sized);
+ SortByEdgeProximity(unique);
+ return unique;
+ }
- for (var r = 0; r < rows; r++)
+ ///
+ /// Finds remnants and splits them into priority tiers based on the
+ /// bounding box of all placed parts (the "used envelope").
+ /// Priority 0: fully within the used envelope — compact, preferred.
+ /// Priority 1: extends past one edge of the envelope.
+ /// Priority 2: fully outside the envelope — last resort.
+ ///
+ public List FindTieredRemnants(double minDimension = 0)
+ {
+ var remnants = FindRemnants(minDimension);
+
+ if (Obstacles.Count == 0 || remnants.Count == 0)
+ return remnants.Select(r => new TieredRemnant(r, 0)).ToList();
+
+ var envelope = ComputeEnvelope();
+ var results = new List();
+
+ foreach (var remnant in remnants)
{
- for (var c = 0; c < cols; c++)
- {
- var cell = new Box(xList[c], yList[r],
- xList[c + 1] - xList[c], yList[r + 1] - yList[r]);
+ var before = results.Count;
+ SplitAtEnvelope(remnant, envelope, minDimension, results);
- empty[r, c] = !OverlapsAnyObstacle(cell);
- }
+ // If all splits fell below minDim, keep the original unsplit.
+ if (results.Count == before)
+ results.Add(new TieredRemnant(remnant, 1));
}
- var merged = MergeCells(empty, xList, yList, rows, cols);
-
- var results = new List();
-
- foreach (var box in merged)
+ results.Sort((a, b) =>
{
- if (box.Width >= minDimension && box.Length >= minDimension)
- results.Add(box);
- }
+ if (a.Priority != b.Priority)
+ return a.Priority.CompareTo(b.Priority);
+ return b.Box.Area().CompareTo(a.Box.Area());
+ });
- results.Sort((a, b) => b.Area().CompareTo(a.Area()));
return results;
}
@@ -88,6 +111,231 @@ namespace OpenNest
return new RemnantFinder(plate.WorkArea(), obstacles);
}
+ private CellGrid BuildGrid()
+ {
+ var clipped = ClipObstacles();
+
+ var xs = new SortedSet { workArea.Left, workArea.Right };
+ var ys = new SortedSet { workArea.Bottom, workArea.Top };
+
+ foreach (var obs in clipped)
+ {
+ xs.Add(obs.Left);
+ xs.Add(obs.Right);
+ ys.Add(obs.Bottom);
+ ys.Add(obs.Top);
+ }
+
+ var grid = new CellGrid
+ {
+ XCoords = xs.ToList(),
+ YCoords = ys.ToList(),
+ };
+
+ grid.Cols = grid.XCoords.Count - 1;
+ grid.Rows = grid.YCoords.Count - 1;
+
+ if (grid.Cols <= 0 || grid.Rows <= 0)
+ {
+ grid.Empty = new bool[0, 0];
+ return grid;
+ }
+
+ grid.Empty = new bool[grid.Rows, grid.Cols];
+
+ for (var r = 0; r < grid.Rows; r++)
+ {
+ for (var c = 0; c < grid.Cols; c++)
+ {
+ var cell = new Box(grid.XCoords[c], grid.YCoords[r],
+ grid.XCoords[c + 1] - grid.XCoords[c],
+ grid.YCoords[r + 1] - grid.YCoords[r]);
+
+ grid.Empty[r, c] = !OverlapsAny(cell, clipped);
+ }
+ }
+
+ return grid;
+ }
+
+ private List ClipObstacles()
+ {
+ var clipped = new List(Obstacles.Count);
+
+ foreach (var obs in Obstacles)
+ {
+ var c = ClipToWorkArea(obs);
+ if (c.Width > 0 && c.Length > 0)
+ clipped.Add(c);
+ }
+
+ return clipped;
+ }
+
+ private static bool OverlapsAny(Box cell, List obstacles)
+ {
+ foreach (var obs in obstacles)
+ {
+ if (cell.Left < obs.Right && cell.Right > obs.Left &&
+ cell.Bottom < obs.Top && cell.Top > obs.Bottom)
+ return true;
+ }
+
+ return false;
+ }
+
+ private static List FilterBySize(List boxes, double minDimension)
+ {
+ if (minDimension <= 0)
+ return boxes;
+
+ var result = new List();
+
+ foreach (var box in boxes)
+ {
+ if (box.Width >= minDimension && box.Length >= minDimension)
+ result.Add(box);
+ }
+
+ return result;
+ }
+
+ private static List RemoveDominated(List boxes)
+ {
+ boxes.Sort((a, b) => b.Area().CompareTo(a.Area()));
+ var results = new List();
+
+ foreach (var box in boxes)
+ {
+ var dominated = false;
+
+ foreach (var larger in results)
+ {
+ if (IsContainedIn(box, larger))
+ {
+ dominated = true;
+ break;
+ }
+ }
+
+ if (!dominated)
+ results.Add(box);
+ }
+
+ return results;
+ }
+
+ private static bool IsContainedIn(Box inner, Box outer)
+ {
+ var eps = Math.Tolerance.Epsilon;
+ return inner.Left >= outer.Left - eps &&
+ inner.Right <= outer.Right + eps &&
+ inner.Bottom >= outer.Bottom - eps &&
+ inner.Top <= outer.Top + eps;
+ }
+
+ private void SortByEdgeProximity(List boxes)
+ {
+ boxes.Sort((a, b) =>
+ {
+ var aEdge = TouchesEdge(a) ? 1 : 0;
+ var bEdge = TouchesEdge(b) ? 1 : 0;
+
+ if (aEdge != bEdge)
+ return bEdge.CompareTo(aEdge);
+
+ return b.Area().CompareTo(a.Area());
+ });
+ }
+
+ private bool TouchesEdge(Box box)
+ {
+ return box.Left <= workArea.Left + Math.Tolerance.Epsilon
+ || box.Right >= workArea.Right - Math.Tolerance.Epsilon
+ || box.Bottom <= workArea.Bottom + Math.Tolerance.Epsilon
+ || box.Top >= workArea.Top - Math.Tolerance.Epsilon;
+ }
+
+ private Box ComputeEnvelope()
+ {
+ var envLeft = double.MaxValue;
+ var envBottom = double.MaxValue;
+ var envRight = double.MinValue;
+ var envTop = double.MinValue;
+
+ foreach (var obs in Obstacles)
+ {
+ if (obs.Left < envLeft) envLeft = obs.Left;
+ if (obs.Bottom < envBottom) envBottom = obs.Bottom;
+ if (obs.Right > envRight) envRight = obs.Right;
+ if (obs.Top > envTop) envTop = obs.Top;
+ }
+
+ return new Box(envLeft, envBottom, envRight - envLeft, envTop - envBottom);
+ }
+
+ private static void SplitAtEnvelope(Box remnant, Box envelope, double minDim, List results)
+ {
+ var eps = Math.Tolerance.Epsilon;
+
+ // Fully within the envelope.
+ if (remnant.Left >= envelope.Left - eps && remnant.Right <= envelope.Right + eps &&
+ remnant.Bottom >= envelope.Bottom - eps && remnant.Top <= envelope.Top + eps)
+ {
+ results.Add(new TieredRemnant(remnant, 0));
+ return;
+ }
+
+ // Fully outside the envelope (no overlap).
+ if (remnant.Left >= envelope.Right - eps || remnant.Right <= envelope.Left + eps ||
+ remnant.Bottom >= envelope.Top - eps || remnant.Top <= envelope.Bottom + eps)
+ {
+ results.Add(new TieredRemnant(remnant, 2));
+ return;
+ }
+
+ // Partially overlapping — split at envelope edges.
+ var innerLeft = System.Math.Max(remnant.Left, envelope.Left);
+ var innerBottom = System.Math.Max(remnant.Bottom, envelope.Bottom);
+ var innerRight = System.Math.Min(remnant.Right, envelope.Right);
+ var innerTop = System.Math.Min(remnant.Top, envelope.Top);
+
+ // Inner portion (priority 0).
+ TryAdd(results, innerLeft, innerBottom, innerRight - innerLeft, innerTop - innerBottom, 0, minDim);
+
+ // Edge extensions (priority 1).
+ if (remnant.Right > envelope.Right + eps)
+ TryAdd(results, envelope.Right, remnant.Bottom, remnant.Right - envelope.Right, remnant.Length, 1, minDim);
+
+ if (remnant.Left < envelope.Left - eps)
+ TryAdd(results, remnant.Left, remnant.Bottom, envelope.Left - remnant.Left, remnant.Length, 1, minDim);
+
+ if (remnant.Top > envelope.Top + eps)
+ TryAdd(results, innerLeft, envelope.Top, innerRight - innerLeft, remnant.Top - envelope.Top, 1, minDim);
+
+ if (remnant.Bottom < envelope.Bottom - eps)
+ TryAdd(results, innerLeft, remnant.Bottom, innerRight - innerLeft, envelope.Bottom - remnant.Bottom, 1, minDim);
+
+ // Corner extensions (priority 2).
+ if (remnant.Right > envelope.Right + eps && remnant.Top > envelope.Top + eps)
+ TryAdd(results, envelope.Right, envelope.Top, remnant.Right - envelope.Right, remnant.Top - envelope.Top, 2, minDim);
+
+ if (remnant.Right > envelope.Right + eps && remnant.Bottom < envelope.Bottom - eps)
+ TryAdd(results, envelope.Right, remnant.Bottom, remnant.Right - envelope.Right, envelope.Bottom - remnant.Bottom, 2, minDim);
+
+ if (remnant.Left < envelope.Left - eps && remnant.Top > envelope.Top + eps)
+ TryAdd(results, remnant.Left, envelope.Top, envelope.Left - remnant.Left, remnant.Top - envelope.Top, 2, minDim);
+
+ if (remnant.Left < envelope.Left - eps && remnant.Bottom < envelope.Bottom - eps)
+ TryAdd(results, remnant.Left, remnant.Bottom, envelope.Left - remnant.Left, envelope.Bottom - remnant.Bottom, 2, minDim);
+ }
+
+ private static void TryAdd(List results, double x, double y, double w, double h, int priority, double minDim)
+ {
+ if (w >= minDim && h >= minDim)
+ results.Add(new TieredRemnant(new Box(x, y, w, h), priority));
+ }
+
private Box ClipToWorkArea(Box obs)
{
var left = System.Math.Max(obs.Left, workArea.Left);
@@ -101,72 +349,49 @@ namespace OpenNest
return new Box(left, bottom, right - left, top - bottom);
}
- private bool OverlapsAnyObstacle(Box cell)
+ ///
+ /// Finds maximal empty rectangles using the histogram method.
+ /// For each row, builds a height histogram of consecutive empty cells
+ /// above, then extracts the largest rectangles from the histogram.
+ ///
+ private static List MergeCells(CellGrid grid)
{
- foreach (var obs in Obstacles)
+ var height = new int[grid.Rows, grid.Cols];
+
+ for (var c = 0; c < grid.Cols; c++)
{
- var clipped = ClipToWorkArea(obs);
-
- if (clipped.Width <= 0 || clipped.Length <= 0)
- continue;
-
- if (cell.Left < clipped.Right &&
- cell.Right > clipped.Left &&
- cell.Bottom < clipped.Top &&
- cell.Top > clipped.Bottom)
- return true;
+ for (var r = 0; r < grid.Rows; r++)
+ height[r, c] = grid.Empty[r, c] ? (r > 0 ? height[r - 1, c] + 1 : 1) : 0;
}
- return false;
- }
+ var candidates = new List();
- private static List MergeCells(bool[,] empty, List xList, List yList, int rows, int cols)
- {
- var used = new bool[rows, cols];
- var results = new List();
-
- for (var r = 0; r < rows; r++)
+ for (var r = 0; r < grid.Rows; r++)
{
- for (var c = 0; c < cols; c++)
+ var stack = new Stack<(int startCol, int h)>();
+
+ for (var c = 0; c <= grid.Cols; c++)
{
- if (!empty[r, c] || used[r, c])
- continue;
+ var h = c < grid.Cols ? height[r, c] : 0;
+ var startCol = c;
- var maxC = c;
- while (maxC + 1 < cols && empty[r, maxC + 1] && !used[r, maxC + 1])
- maxC++;
-
- var maxR = r;
- while (maxR + 1 < rows)
+ while (stack.Count > 0 && stack.Peek().h > h)
{
- var rowOk = true;
- for (var cc = c; cc <= maxC; cc++)
- {
- if (!empty[maxR + 1, cc] || used[maxR + 1, cc])
- {
- rowOk = false;
- break;
- }
- }
+ var top = stack.Pop();
+ startCol = top.startCol;
- if (!rowOk) break;
- maxR++;
+ candidates.Add(new Box(
+ grid.XCoords[top.startCol], grid.YCoords[r - top.h + 1],
+ grid.XCoords[c] - grid.XCoords[top.startCol],
+ grid.YCoords[r + 1] - grid.YCoords[r - top.h + 1]));
}
- for (var rr = r; rr <= maxR; rr++)
- for (var cc = c; cc <= maxC; cc++)
- used[rr, cc] = true;
-
- var box = new Box(
- xList[c], yList[r],
- xList[maxC + 1] - xList[c],
- yList[maxR + 1] - yList[r]);
-
- results.Add(box);
+ if (h > 0)
+ stack.Push((startCol, h));
}
}
- return results;
+ return candidates;
}
}
}
diff --git a/OpenNest.Engine/RotationAnalysis.cs b/OpenNest.Engine/RotationAnalysis.cs
index 0d7e20f..e79af6f 100644
--- a/OpenNest.Engine/RotationAnalysis.cs
+++ b/OpenNest.Engine/RotationAnalysis.cs
@@ -88,19 +88,32 @@ namespace OpenNest
var vertices = hull.Vertices;
var n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count;
- var angles = new List { 0 };
+ // Collect edges with their squared length so we can sort by longest first.
+ var edges = new List<(double angle, double lengthSq)>();
for (var i = 0; i < n; i++)
{
var next = (i + 1) % n;
var dx = vertices[next].X - vertices[i].X;
var dy = vertices[next].Y - vertices[i].Y;
+ var lengthSq = dx * dx + dy * dy;
- if (dx * dx + dy * dy < Tolerance.Epsilon)
+ if (lengthSq < Tolerance.Epsilon)
continue;
var angle = -System.Math.Atan2(dy, dx);
+ if (!edges.Any(e => e.angle.IsEqualTo(angle)))
+ edges.Add((angle, lengthSq));
+ }
+
+ // Longest edges first — they produce the flattest tiling rows.
+ edges.Sort((a, b) => b.lengthSq.CompareTo(a.lengthSq));
+
+ var angles = new List(edges.Count + 1) { 0 };
+
+ foreach (var (angle, _) in edges)
+ {
if (!angles.Any(a => a.IsEqualTo(angle)))
angles.Add(angle);
}
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.Engine/StripNestEngine.cs b/OpenNest.Engine/StripNestEngine.cs
index 6e21279..a2da5f1 100644
--- a/OpenNest.Engine/StripNestEngine.cs
+++ b/OpenNest.Engine/StripNestEngine.cs
@@ -3,14 +3,11 @@ 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)
{
}
@@ -165,54 +162,25 @@ namespace OpenNest
? 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, progress, token);
+ // Shrink to tightest strip.
+ var shrinkAxis = direction == StripDirection.Bottom
+ ? ShrinkAxis.Height : ShrinkAxis.Width;
- if (stripParts == null || stripParts.Count == 0)
+ Func> stripFill = (ni, b) =>
+ {
+ var trialInner = new DefaultNestEngine(Plate);
+ return trialInner.Fill(ni, b, progress, token);
+ };
+
+ var shrinkResult = ShrinkFiller.Shrink(stripFill,
+ new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
+ stripBox, Plate.PartSpacing, shrinkAxis, token);
+
+ if (shrinkResult.Parts == null || shrinkResult.Parts.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, progress, 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;
- }
+ var bestParts = shrinkResult.Parts;
+ var bestDim = shrinkResult.Dimension;
// TODO: Compact strip parts individually to close geometry-based gaps.
// Disabled pending investigation — remnant finder picks up gaps created
@@ -258,88 +226,23 @@ namespace OpenNest
})
.ToList();
- // Fill remnant areas iteratively using RemnantFinder.
- // After each fill, re-discover all free rectangles and try again
- // until no more items can be placed.
+ // Fill remnants
if (remnantBox.Width > 0 && remnantBox.Length > 0)
{
var remnantProgress = progress != null
? new AccumulatingProgress(progress, allParts)
- : null;
+ : (IProgress)null;
- var obstacles = allParts.Select(p => p.BoundingBox.Offset(spacing)).ToList();
- var finder = new RemnantFinder(workArea, obstacles);
- var madeProgress = true;
+ var remnantFiller = new RemnantFiller(workArea, spacing);
+ remnantFiller.AddObstacles(allParts);
- // Track quantities locally so we don't mutate the shared NestItem objects.
- // TryOrientation is called twice (bottom, left) with the same items.
- var localQty = new Dictionary();
- foreach (var item in effectiveRemainder)
- localQty[item.Drawing.Name] = item.Quantity;
+ Func> remnantFillFunc = (ni, b) =>
+ ShrinkFill(ni, b, remnantProgress, token);
- while (madeProgress && !token.IsCancellationRequested)
- {
- madeProgress = false;
+ var additional = remnantFiller.FillItems(effectiveRemainder,
+ remnantFillFunc, token, remnantProgress);
- // Minimum remnant size = smallest remaining part dimension
- var minRemnantDim = double.MaxValue;
- foreach (var item in effectiveRemainder)
- {
- if (localQty[item.Drawing.Name] <= 0)
- continue;
- var bb = item.Drawing.Program.BoundingBox();
- var dim = System.Math.Min(bb.Width, bb.Length);
- if (dim < minRemnantDim)
- minRemnantDim = dim;
- }
-
- if (minRemnantDim == double.MaxValue)
- break; // No items with remaining quantity
-
- var freeBoxes = finder.FindRemnants(minRemnantDim);
-
- if (freeBoxes.Count == 0)
- break;
-
- foreach (var item in effectiveRemainder)
- {
- if (token.IsCancellationRequested)
- break;
-
- var qty = localQty[item.Drawing.Name];
- if (qty == 0)
- continue;
-
- var itemBbox = item.Drawing.Program.BoundingBox();
- var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length);
-
- foreach (var box in freeBoxes)
- {
- if (System.Math.Min(box.Width, box.Length) < minItemDim)
- continue;
-
- var remnantParts = ShrinkFill(
- new NestItem { Drawing = item.Drawing, Quantity = qty },
- box, remnantProgress, token);
-
- if (remnantParts != null && remnantParts.Count > 0)
- {
- allParts.AddRange(remnantParts);
- localQty[item.Drawing.Name] = System.Math.Max(0, qty - remnantParts.Count);
-
- // Update obstacles and re-discover remnants
- foreach (var p in remnantParts)
- finder.AddObstacle(p.BoundingBox.Offset(spacing));
-
- madeProgress = true;
- break; // Re-discover free boxes with updated obstacles
- }
- }
-
- if (madeProgress)
- break; // Restart the outer loop to re-discover remnants
- }
- }
+ allParts.AddRange(additional);
}
result.Parts = allParts;
@@ -351,101 +254,20 @@ namespace OpenNest
return result;
}
- ///
- /// Fill a box and then shrink it to the tightest area that still fits
- /// the same number of parts. This maximizes leftover space for subsequent fills.
- ///
private List ShrinkFill(NestItem item, Box box,
IProgress progress, CancellationToken token)
{
- var inner = new DefaultNestEngine(Plate);
- var parts = inner.Fill(item, box, progress, token);
-
- if (parts == null || parts.Count < 2)
- return parts;
-
- var targetCount = parts.Count;
- var placedBox = parts.Cast().GetBoundingBox();
-
- // Try shrinking horizontally
- var bestParts = parts;
- var shrunkWidth = placedBox.Right - box.X;
- var shrunkHeight = placedBox.Top - box.Y;
-
- for (var i = 0; i < MaxShrinkIterations; i++)
+ Func> fillFunc = (ni, b) =>
{
- if (token.IsCancellationRequested)
- break;
+ var inner = new DefaultNestEngine(Plate);
+ return inner.Fill(ni, b, null, token);
+ };
- var trialWidth = shrunkWidth - Plate.PartSpacing;
- if (trialWidth <= 0)
- break;
+ var heightResult = ShrinkFiller.Shrink(fillFunc, item, box,
+ Plate.PartSpacing, ShrinkAxis.Height, token);
- var trialBox = new Box(box.X, box.Y, trialWidth, box.Length);
- var trialInner = new DefaultNestEngine(Plate);
- var trialParts = trialInner.Fill(item, trialBox, null, token);
-
- if (trialParts == null || trialParts.Count < targetCount)
- break;
-
- bestParts = trialParts;
- var trialPlacedBox = trialParts.Cast().GetBoundingBox();
- shrunkWidth = trialPlacedBox.Right - box.X;
- }
-
- // Try shrinking vertically
- for (var i = 0; i < MaxShrinkIterations; i++)
- {
- if (token.IsCancellationRequested)
- break;
-
- var trialHeight = shrunkHeight - Plate.PartSpacing;
- if (trialHeight <= 0)
- break;
-
- var trialBox = new Box(box.X, box.Y, box.Width, trialHeight);
- var trialInner = new DefaultNestEngine(Plate);
- var trialParts = trialInner.Fill(item, trialBox, null, token);
-
- if (trialParts == null || trialParts.Count < targetCount)
- break;
-
- bestParts = trialParts;
- var trialPlacedBox = trialParts.Cast().GetBoundingBox();
- shrunkHeight = trialPlacedBox.Top - box.Y;
- }
-
- return bestParts;
+ return heightResult.Parts;
}
- ///
- /// Wraps an IProgress to prepend previously placed parts to each report,
- /// so the UI shows the full picture (strip + remnant) during remnant fills.
- ///
- private class AccumulatingProgress : IProgress
- {
- private readonly IProgress inner;
- private readonly List previousParts;
-
- public AccumulatingProgress(IProgress inner, List previousParts)
- {
- this.inner = inner;
- this.previousParts = previousParts;
- }
-
- public void Report(NestProgress value)
- {
- if (value.BestParts != null && previousParts.Count > 0)
- {
- var combined = new List(previousParts.Count + value.BestParts.Count);
- combined.AddRange(previousParts);
- combined.AddRange(value.BestParts);
- value.BestParts = combined;
- value.BestPartCount = combined.Count;
- }
-
- inner.Report(value);
- }
- }
}
}
diff --git a/OpenNest.Tests/AccumulatingProgressTests.cs b/OpenNest.Tests/AccumulatingProgressTests.cs
new file mode 100644
index 0000000..2df42c5
--- /dev/null
+++ b/OpenNest.Tests/AccumulatingProgressTests.cs
@@ -0,0 +1,51 @@
+namespace OpenNest.Tests;
+
+public class AccumulatingProgressTests
+{
+ private class CapturingProgress : IProgress
+ {
+ public NestProgress Last { get; private set; }
+ public void Report(NestProgress value) => Last = value;
+ }
+
+ [Fact]
+ public void Report_PrependsPreviousParts()
+ {
+ var inner = new CapturingProgress();
+ var previous = new List { TestHelpers.MakePartAt(0, 0, 10) };
+ var accumulating = new AccumulatingProgress(inner, previous);
+
+ var newParts = new List { TestHelpers.MakePartAt(20, 0, 10) };
+ accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 });
+
+ Assert.NotNull(inner.Last);
+ Assert.Equal(2, inner.Last.BestParts.Count);
+ Assert.Equal(2, inner.Last.BestPartCount);
+ }
+
+ [Fact]
+ public void Report_NoPreviousParts_PassesThrough()
+ {
+ var inner = new CapturingProgress();
+ var accumulating = new AccumulatingProgress(inner, new List());
+
+ var newParts = new List { TestHelpers.MakePartAt(0, 0, 10) };
+ accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 });
+
+ Assert.NotNull(inner.Last);
+ Assert.Single(inner.Last.BestParts);
+ }
+
+ [Fact]
+ public void Report_NullBestParts_PassesThrough()
+ {
+ var inner = new CapturingProgress();
+ var previous = new List { TestHelpers.MakePartAt(0, 0, 10) };
+ var accumulating = new AccumulatingProgress(inner, previous);
+
+ accumulating.Report(new NestProgress { BestParts = null });
+
+ Assert.NotNull(inner.Last);
+ Assert.Null(inner.Last.BestParts);
+ }
+}
diff --git a/OpenNest.Tests/AngleCandidateBuilderTests.cs b/OpenNest.Tests/AngleCandidateBuilderTests.cs
new file mode 100644
index 0000000..29d26bf
--- /dev/null
+++ b/OpenNest.Tests/AngleCandidateBuilderTests.cs
@@ -0,0 +1,83 @@
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests;
+
+public class AngleCandidateBuilderTests
+{
+ private static Drawing MakeRectDrawing(double w, double h)
+ {
+ 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("rect", pgm);
+ }
+
+ [Fact]
+ public void Build_ReturnsAtLeastTwoAngles()
+ {
+ var builder = new AngleCandidateBuilder();
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
+ var workArea = new Box(0, 0, 100, 100);
+
+ var angles = builder.Build(item, 0, workArea);
+
+ Assert.True(angles.Count >= 2);
+ }
+
+ [Fact]
+ public void Build_NarrowWorkArea_ProducesMoreAngles()
+ {
+ var builder = new AngleCandidateBuilder();
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
+ var wideArea = new Box(0, 0, 100, 100);
+ var narrowArea = new Box(0, 0, 100, 8); // narrower than part's longest side
+
+ var wideAngles = builder.Build(item, 0, wideArea);
+ var narrowAngles = builder.Build(item, 0, narrowArea);
+
+ Assert.True(narrowAngles.Count > wideAngles.Count,
+ $"Narrow ({narrowAngles.Count}) should have more angles than wide ({wideAngles.Count})");
+ }
+
+ [Fact]
+ public void ForceFullSweep_ProducesFullSweep()
+ {
+ var builder = new AngleCandidateBuilder { ForceFullSweep = true };
+ var item = new NestItem { Drawing = MakeRectDrawing(5, 5) };
+ var workArea = new Box(0, 0, 100, 100);
+
+ var angles = builder.Build(item, 0, workArea);
+
+ // Full sweep at 5deg steps = ~36 angles (0 to 175), plus base angles
+ Assert.True(angles.Count > 10);
+ }
+
+ [Fact]
+ public void RecordProductive_PrunesSubsequentBuilds()
+ {
+ var builder = new AngleCandidateBuilder { ForceFullSweep = true };
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
+ var workArea = new Box(0, 0, 100, 8);
+
+ // First build — full sweep
+ var firstAngles = builder.Build(item, 0, workArea);
+
+ // Record some as productive
+ var productive = new List
+ {
+ new AngleResult { AngleDeg = 0, PartCount = 5 },
+ new AngleResult { AngleDeg = 45, PartCount = 3 },
+ };
+ builder.RecordProductive(productive);
+
+ // Second build — should be pruned to known-good + base angles
+ builder.ForceFullSweep = false;
+ var secondAngles = builder.Build(item, 0, workArea);
+
+ Assert.True(secondAngles.Count < firstAngles.Count,
+ $"Pruned ({secondAngles.Count}) should be fewer than full ({firstAngles.Count})");
+ }
+}
diff --git a/OpenNest.Tests/EngineRefactorSmokeTests.cs b/OpenNest.Tests/EngineRefactorSmokeTests.cs
new file mode 100644
index 0000000..3e4e8b3
--- /dev/null
+++ b/OpenNest.Tests/EngineRefactorSmokeTests.cs
@@ -0,0 +1,99 @@
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests;
+
+public class EngineRefactorSmokeTests
+{
+ 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 DefaultEngine_FillNestItem_ProducesResults()
+ {
+ var plate = new Plate(120, 60);
+ var engine = new DefaultNestEngine(plate);
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
+
+ var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
+
+ Assert.True(parts.Count > 0, "DefaultNestEngine should fill parts");
+ }
+
+ [Fact]
+ public void DefaultEngine_FillGroupParts_ProducesResults()
+ {
+ var plate = new Plate(120, 60);
+ var engine = new DefaultNestEngine(plate);
+ var drawing = MakeRectDrawing(20, 10);
+ var groupParts = new List { new Part(drawing) };
+
+ var parts = engine.Fill(groupParts, plate.WorkArea(), null, System.Threading.CancellationToken.None);
+
+ Assert.True(parts.Count > 0, "DefaultNestEngine group fill should produce parts");
+ }
+
+ [Fact]
+ public void DefaultEngine_ForceFullAngleSweep_StillWorks()
+ {
+ var plate = new Plate(120, 60);
+ var engine = new DefaultNestEngine(plate);
+ engine.ForceFullAngleSweep = true;
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
+
+ var parts = engine.Fill(item, plate.WorkArea(), null, System.Threading.CancellationToken.None);
+
+ Assert.True(parts.Count > 0, "ForceFullAngleSweep should still produce results");
+ }
+
+ [Fact]
+ public void StripEngine_Nest_ProducesResults()
+ {
+ var plate = new Plate(120, 60);
+ var engine = new StripNestEngine(plate);
+ var items = new List
+ {
+ new NestItem { Drawing = MakeRectDrawing(20, 10, "large"), Quantity = 10 },
+ new NestItem { Drawing = MakeRectDrawing(8, 5, "small"), Quantity = 5 },
+ };
+
+ var parts = engine.Nest(items, null, System.Threading.CancellationToken.None);
+
+ Assert.True(parts.Count > 0, "StripNestEngine should nest parts");
+ }
+
+ [Fact]
+ public void DefaultEngine_Nest_ProducesResults()
+ {
+ var plate = new Plate(120, 60);
+ var engine = new DefaultNestEngine(plate);
+ var items = new List
+ {
+ new NestItem { Drawing = MakeRectDrawing(20, 10, "a"), Quantity = 5 },
+ new NestItem { Drawing = MakeRectDrawing(15, 8, "b"), Quantity = 3 },
+ };
+
+ var parts = engine.Nest(items, null, System.Threading.CancellationToken.None);
+
+ Assert.True(parts.Count > 0, "Base Nest method should place parts");
+ }
+
+ [Fact]
+ public void BruteForceRunner_StillWorks()
+ {
+ var plate = new Plate(120, 60);
+ var drawing = MakeRectDrawing(20, 10);
+
+ var result = OpenNest.Engine.ML.BruteForceRunner.Run(drawing, plate, forceFullAngleSweep: true);
+
+ Assert.NotNull(result);
+ Assert.True(result.PartCount > 0);
+ }
+}
diff --git a/OpenNest.Tests/PairFillerTests.cs b/OpenNest.Tests/PairFillerTests.cs
new file mode 100644
index 0000000..d2199b7
--- /dev/null
+++ b/OpenNest.Tests/PairFillerTests.cs
@@ -0,0 +1,63 @@
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests;
+
+public class PairFillerTests
+{
+ private static Drawing MakeRectDrawing(double w, double h)
+ {
+ 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("rect", pgm);
+ }
+
+ [Fact]
+ public void Fill_ReturnsPartsForSimpleDrawing()
+ {
+ var plateSize = new Size(120, 60);
+ var filler = new PairFiller(plateSize, 0.5);
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
+ var workArea = new Box(0, 0, 120, 60);
+
+ var parts = filler.Fill(item, workArea);
+
+ Assert.NotNull(parts);
+ // Pair filling may or may not find interlocking pairs for rectangles,
+ // but should return a non-null list.
+ }
+
+ [Fact]
+ public void Fill_EmptyResult_WhenPartTooLarge()
+ {
+ var plateSize = new Size(10, 10);
+ var filler = new PairFiller(plateSize, 0.5);
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 20) };
+ var workArea = new Box(0, 0, 10, 10);
+
+ var parts = filler.Fill(item, workArea);
+
+ Assert.NotNull(parts);
+ Assert.Empty(parts);
+ }
+
+ [Fact]
+ public void Fill_RespectsCancellation()
+ {
+ var cts = new System.Threading.CancellationTokenSource();
+ cts.Cancel();
+
+ var plateSize = new Size(120, 60);
+ var filler = new PairFiller(plateSize, 0.5);
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
+ var workArea = new Box(0, 0, 120, 60);
+
+ var parts = filler.Fill(item, workArea, token: cts.Token);
+
+ // Should return empty or partial — not throw
+ Assert.NotNull(parts);
+ }
+}
diff --git a/OpenNest.Tests/PolyLabelTests.cs b/OpenNest.Tests/PolyLabelTests.cs
new file mode 100644
index 0000000..97e4aa3
--- /dev/null
+++ b/OpenNest.Tests/PolyLabelTests.cs
@@ -0,0 +1,129 @@
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests;
+
+public class PolyLabelTests
+{
+ private static Polygon Square(double size)
+ {
+ var p = new Polygon();
+ p.Vertices.Add(new Vector(0, 0));
+ p.Vertices.Add(new Vector(size, 0));
+ p.Vertices.Add(new Vector(size, size));
+ p.Vertices.Add(new Vector(0, size));
+ return p;
+ }
+
+ [Fact]
+ public void Square_ReturnsCenterPoint()
+ {
+ var poly = Square(100);
+
+ var result = PolyLabel.Find(poly);
+
+ Assert.Equal(50, result.X, 1.0);
+ Assert.Equal(50, result.Y, 1.0);
+ }
+
+ [Fact]
+ public void Triangle_ReturnsIncenter()
+ {
+ var p = new Polygon();
+ p.Vertices.Add(new Vector(0, 0));
+ p.Vertices.Add(new Vector(100, 0));
+ p.Vertices.Add(new Vector(50, 86.6));
+
+ var result = PolyLabel.Find(p);
+
+ // Incenter of equilateral triangle is at (50, ~28.9)
+ Assert.Equal(50, result.X, 1.0);
+ Assert.Equal(28.9, result.Y, 1.0);
+ Assert.True(p.ContainsPoint(result));
+ }
+
+ [Fact]
+ public void LShape_ReturnsPointInBottomLobe()
+ {
+ // L-shape: 100x100 with 50x50 cut from top-right
+ var p = new Polygon();
+ p.Vertices.Add(new Vector(0, 0));
+ p.Vertices.Add(new Vector(100, 0));
+ p.Vertices.Add(new Vector(100, 50));
+ p.Vertices.Add(new Vector(50, 50));
+ p.Vertices.Add(new Vector(50, 100));
+ p.Vertices.Add(new Vector(0, 100));
+
+ var result = PolyLabel.Find(p);
+
+ Assert.True(p.ContainsPoint(result));
+ // The bottom 100x50 lobe is the widest region
+ Assert.True(result.Y < 50, $"Expected label in bottom lobe, got Y={result.Y}");
+ }
+
+ [Fact]
+ public void ThinRectangle_CenteredOnBothAxes()
+ {
+ var p = new Polygon();
+ p.Vertices.Add(new Vector(0, 0));
+ p.Vertices.Add(new Vector(200, 0));
+ p.Vertices.Add(new Vector(200, 10));
+ p.Vertices.Add(new Vector(0, 10));
+
+ var result = PolyLabel.Find(p);
+
+ Assert.Equal(100, result.X, 1.0);
+ Assert.Equal(5, result.Y, 1.0);
+ Assert.True(p.ContainsPoint(result));
+ }
+
+ [Fact]
+ public void SquareWithLargeHole_AvoidsHole()
+ {
+ var outer = Square(100);
+
+ var hole = new Polygon();
+ hole.Vertices.Add(new Vector(20, 20));
+ hole.Vertices.Add(new Vector(80, 20));
+ hole.Vertices.Add(new Vector(80, 80));
+ hole.Vertices.Add(new Vector(20, 80));
+
+ var result = PolyLabel.Find(outer, new[] { hole });
+
+ // Point should be inside outer but outside hole
+ Assert.True(outer.ContainsPoint(result));
+ Assert.False(hole.ContainsPoint(result));
+ }
+
+ [Fact]
+ public void CShape_ReturnsPointInLeftBar()
+ {
+ // C-shape opening to the right: left bar is 20 wide, top/bottom arms are 20 tall
+ var p = new Polygon();
+ p.Vertices.Add(new Vector(0, 0));
+ p.Vertices.Add(new Vector(100, 0));
+ p.Vertices.Add(new Vector(100, 20));
+ p.Vertices.Add(new Vector(20, 20));
+ p.Vertices.Add(new Vector(20, 80));
+ p.Vertices.Add(new Vector(100, 80));
+ p.Vertices.Add(new Vector(100, 100));
+ p.Vertices.Add(new Vector(0, 100));
+
+ var result = PolyLabel.Find(p);
+
+ Assert.True(p.ContainsPoint(result));
+ // Label should be in the left vertical bar (x < 20), not at bbox center (50, 50)
+ Assert.True(result.X < 20, $"Expected label in left bar, got X={result.X}");
+ }
+
+ [Fact]
+ public void DegeneratePolygon_ReturnsFallback()
+ {
+ var p = new Polygon();
+ p.Vertices.Add(new Vector(5, 5));
+
+ var result = PolyLabel.Find(p);
+
+ Assert.Equal(5, result.X, 0.01);
+ Assert.Equal(5, result.Y, 0.01);
+ }
+}
diff --git a/OpenNest.Tests/RemnantFillerTests2.cs b/OpenNest.Tests/RemnantFillerTests2.cs
new file mode 100644
index 0000000..2ee8c6a
--- /dev/null
+++ b/OpenNest.Tests/RemnantFillerTests2.cs
@@ -0,0 +1,105 @@
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests;
+
+public class RemnantFillerTests2
+{
+ 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("sq", pgm);
+ }
+
+ [Fact]
+ public void FillItems_PlacesPartsInRemnants()
+ {
+ var workArea = new Box(0, 0, 100, 100);
+ var filler = new RemnantFiller(workArea, 1.0);
+
+ // Place a large obstacle leaving a 40x100 strip on the right
+ filler.AddObstacles(new[] { TestHelpers.MakePartAt(0, 0, 50) });
+
+ var drawing = MakeSquareDrawing(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 placed = filler.FillItems(items, fillFunc);
+
+ Assert.True(placed.Count > 0, "Should place parts in remaining space");
+ }
+
+ [Fact]
+ public void FillItems_DoesNotMutateItemQuantities()
+ {
+ var workArea = new Box(0, 0, 100, 100);
+ var filler = new RemnantFiller(workArea, 1.0);
+
+ var drawing = MakeSquareDrawing(10);
+ var items = new List
+ {
+ new NestItem { Drawing = drawing, Quantity = 3 }
+ };
+
+ 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);
+ };
+
+ filler.FillItems(items, fillFunc);
+
+ Assert.Equal(3, items[0].Quantity);
+ }
+
+ [Fact]
+ public void FillItems_EmptyItems_ReturnsEmpty()
+ {
+ var workArea = new Box(0, 0, 100, 100);
+ var filler = new RemnantFiller(workArea, 1.0);
+
+ Func> fillFunc = (ni, b) => new List();
+
+ var result = filler.FillItems(new List(), fillFunc);
+
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void FillItems_RespectsCancellation()
+ {
+ var cts = new System.Threading.CancellationTokenSource();
+ cts.Cancel();
+
+ var workArea = new Box(0, 0, 100, 100);
+ var filler = new RemnantFiller(workArea, 1.0);
+
+ var drawing = MakeSquareDrawing(10);
+ var items = new List
+ {
+ new NestItem { Drawing = drawing, Quantity = 5 }
+ };
+
+ Func> fillFunc = (ni, b) =>
+ new List { TestHelpers.MakePartAt(0, 0, 10) };
+
+ var result = filler.FillItems(items, fillFunc, cts.Token);
+
+ // Should not throw, returns whatever was placed
+ Assert.NotNull(result);
+ }
+}
diff --git a/OpenNest.Tests/RemnantFinderTests.cs b/OpenNest.Tests/RemnantFinderTests.cs
index db4d154..45734c5 100644
--- a/OpenNest.Tests/RemnantFinderTests.cs
+++ b/OpenNest.Tests/RemnantFinderTests.cs
@@ -1,4 +1,5 @@
using OpenNest.Geometry;
+using OpenNest.IO;
namespace OpenNest.Tests;
@@ -269,4 +270,101 @@ public class RemnantFinderTests
// Should find gaps between obstacles
Assert.True(remnants.Count > 0);
}
+
+ [Fact]
+ public void SingleObstacle_NearEdge_FindsRemnantsOnAllSides()
+ {
+ // Obstacle near top-left: should find remnants above, below, and to the right.
+ var finder = new RemnantFinder(new Box(0, 0, 120, 60));
+ finder.AddObstacle(new Box(0, 47, 21, 6));
+ var remnants = finder.FindRemnants();
+
+ var above = remnants.FirstOrDefault(r => r.Bottom >= 53 - 0.1 && r.Width > 50);
+ var below = remnants.FirstOrDefault(r => r.Top <= 47 + 0.1 && r.Width > 50);
+ var right = remnants.FirstOrDefault(r => r.Left >= 21 - 0.1 && r.Length > 50);
+
+ Assert.NotNull(above);
+ Assert.NotNull(below);
+ Assert.NotNull(right);
+ }
+
+ [Fact]
+ public void LoadNestFile_FindsGapAboveMainGrid()
+ {
+ var nestFile = @"C:\Users\AJ\Desktop\no_remnant_found.nest";
+ if (!File.Exists(nestFile))
+ return; // Skip if file not available.
+
+ var reader = new NestReader(nestFile);
+ var nest = reader.Read();
+ var plate = nest.Plates[0];
+
+ var finder = RemnantFinder.FromPlate(plate);
+
+ // Use smallest drawing bbox dimension as minDim (same as UI).
+ var minDim = nest.Drawings.Min(d =>
+ System.Math.Min(d.Program.BoundingBox().Width, d.Program.BoundingBox().Length));
+
+ var tiered = finder.FindTieredRemnants(minDim);
+
+ // Should find a remnant near (0.25, 53.13) — the gap above the main grid.
+ var topGap = tiered.FirstOrDefault(t =>
+ t.Box.Bottom > 50 && t.Box.Bottom < 55 &&
+ t.Box.Left < 1 &&
+ t.Box.Width > 100 &&
+ t.Box.Length > 5);
+
+ Assert.True(topGap.Box.Width > 0, "Expected remnant above main grid");
+ }
+
+ [Fact]
+ public void DensePack_FindsGapAtTop()
+ {
+ // Reproduce real plate: 120x60, 68 parts of SULLYS-004.
+ // Main grid tops out at y=53.14 (obstacle). Two rotated parts on the
+ // right extend to y=58.49 but only at x > 106. The gap at x < 106
+ // from y=53.14 to y=59.8 is ~106 x 6.66 — should be found.
+ var workArea = new Box(0.2, 0.8, 119.5, 59.0);
+ var obstacles = new List();
+ var spacing = 0.25;
+
+ // Main grid: 5 columns x 12 rows (6 pairs).
+ // Even rows: bbox bottom offsets, odd rows: different offsets.
+ double[] colX = { 0.25, 21.08, 41.90, 62.73, 83.56 };
+ double[] colXOdd = { 0.81, 21.64, 42.46, 63.29, 84.12 };
+ double[] evenY = { 3.67, 12.41, 21.14, 29.87, 38.60, 47.33 };
+ double[] oddY = { 0.75, 9.48, 18.21, 26.94, 35.67, 44.40 };
+
+ foreach (var cx in colX)
+ foreach (var ey in evenY)
+ obstacles.Add(new Box(cx - spacing, ey - spacing, 20.65 + spacing * 2, 5.56 + spacing * 2));
+ foreach (var cx in colXOdd)
+ foreach (var oy in oddY)
+ obstacles.Add(new Box(cx - spacing, oy - spacing, 20.65 + spacing * 2, 5.56 + spacing * 2));
+
+ // Right-side rotated parts (only 2 extend high: parts 62 and 66).
+ obstacles.Add(new Box(106.70 - spacing, 37.59 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
+ obstacles.Add(new Box(114.19 - spacing, 37.59 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
+ // Parts 63, 67 (lower rotated)
+ obstacles.Add(new Box(105.02 - spacing, 29.35 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
+ obstacles.Add(new Box(112.51 - spacing, 29.35 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
+ // Parts 60, 64 (upper-right rotated, lower)
+ obstacles.Add(new Box(106.70 - spacing, 8.99 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
+ obstacles.Add(new Box(114.19 - spacing, 8.99 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
+ // Parts 61, 65
+ obstacles.Add(new Box(105.02 - spacing, 0.75 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
+ obstacles.Add(new Box(112.51 - spacing, 0.75 - spacing, 5.56 + spacing * 2, 20.65 + spacing * 2));
+
+ var finder = new RemnantFinder(workArea, obstacles);
+ var remnants = finder.FindRemnants(5.375);
+
+ // The gap at x < 106 from y=53.14 to y=59.8 should be found.
+ Assert.True(remnants.Count > 0, "Should find gap above main grid");
+ var topRemnant = remnants.FirstOrDefault(r => r.Length >= 5.375 && r.Width > 50);
+ Assert.NotNull(topRemnant);
+
+ // Verify dimensions are close to the expected ~104 x 6.6 gap.
+ Assert.True(topRemnant.Width > 100, $"Expected width > 100, got {topRemnant.Width:F1}");
+ Assert.True(topRemnant.Length > 6, $"Expected length > 6, got {topRemnant.Length:F1}");
+ }
}
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);
+ }
+}
diff --git a/OpenNest/Controls/PlateView.cs b/OpenNest/Controls/PlateView.cs
index a099c1d..f093930 100644
--- a/OpenNest/Controls/PlateView.cs
+++ b/OpenNest/Controls/PlateView.cs
@@ -33,6 +33,7 @@ namespace OpenNest.Controls
private List temporaryParts = new List();
private Point middleMouseDownPoint;
private Box activeWorkArea;
+ private List debugRemnants;
public Box ActiveWorkArea
{
@@ -44,6 +45,18 @@ namespace OpenNest.Controls
}
}
+ public List DebugRemnants
+ {
+ get => debugRemnants;
+ set
+ {
+ debugRemnants = value;
+ Invalidate();
+ }
+ }
+
+ public List DebugRemnantPriorities { get; set; }
+
public List SelectedParts;
public ReadOnlyCollection Parts;
@@ -374,6 +387,7 @@ namespace OpenNest.Controls
DrawPlate(e.Graphics);
DrawParts(e.Graphics);
DrawActiveWorkArea(e.Graphics);
+ DrawDebugRemnants(e.Graphics);
base.OnPaint(e);
}
@@ -632,6 +646,51 @@ namespace OpenNest.Controls
g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height);
}
+ // Priority 0 = green (preferred), 1 = yellow (extend), 2 = red (last resort)
+ private static readonly Color[] PriorityFills =
+ {
+ Color.FromArgb(60, Color.LimeGreen),
+ Color.FromArgb(60, Color.Gold),
+ Color.FromArgb(60, Color.Salmon),
+ };
+
+ private static readonly Color[] PriorityBorders =
+ {
+ Color.FromArgb(180, Color.Green),
+ Color.FromArgb(180, Color.DarkGoldenrod),
+ Color.FromArgb(180, Color.DarkRed),
+ };
+
+ private void DrawDebugRemnants(Graphics g)
+ {
+ if (debugRemnants == null || debugRemnants.Count == 0)
+ return;
+
+ for (var i = 0; i < debugRemnants.Count; i++)
+ {
+ var box = debugRemnants[i];
+ var loc = PointWorldToGraph(box.Location);
+ var w = LengthWorldToGui(box.Width);
+ var h = LengthWorldToGui(box.Length);
+ var rect = new RectangleF(loc.X, loc.Y - h, w, h);
+
+ var priority = DebugRemnantPriorities != null && i < DebugRemnantPriorities.Count
+ ? System.Math.Min(DebugRemnantPriorities[i], 2)
+ : 0;
+
+ using var brush = new SolidBrush(PriorityFills[priority]);
+ g.FillRectangle(brush, rect);
+
+ using var pen = new Pen(PriorityBorders[priority], 1.5f);
+ g.DrawRectangle(pen, rect.X, rect.Y, rect.Width, rect.Height);
+
+ var label = $"P{priority} {box.Width:F1}x{box.Length:F1}";
+ using var font = new Font("Segoe UI", 8f);
+ using var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
+ g.DrawString(label, font, Brushes.Black, rect, sf);
+ }
+ }
+
public LayoutPart GetPartAtControlPoint(Point pt)
{
var pt2 = PointControlToGraph(pt);
diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs
index 6aad83f..28cb553 100644
--- a/OpenNest/Forms/MainForm.Designer.cs
+++ b/OpenNest/Forms/MainForm.Designer.cs
@@ -149,6 +149,7 @@
engineLabel = new System.Windows.Forms.ToolStripLabel();
engineComboBox = new System.Windows.Forms.ToolStripComboBox();
btnAutoNest = new System.Windows.Forms.ToolStripButton();
+ btnShowRemnants = new System.Windows.Forms.ToolStripButton();
pEPToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
openNestToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
menuStrip1.SuspendLayout();
@@ -888,7 +889,7 @@
// toolStrip1
//
toolStrip1.AutoSize = false;
- toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { btnNew, btnOpen, btnSave, btnSaveAs, toolStripSeparator1, btnFirstPlate, btnPreviousPlate, btnNextPlate, btnLastPlate, toolStripSeparator3, btnZoomOut, btnZoomIn, btnZoomToFit, toolStripSeparator4, engineLabel, engineComboBox, btnAutoNest });
+ toolStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { btnNew, btnOpen, btnSave, btnSaveAs, toolStripSeparator1, btnFirstPlate, btnPreviousPlate, btnNextPlate, btnLastPlate, toolStripSeparator3, btnZoomOut, btnZoomIn, btnZoomToFit, toolStripSeparator4, engineLabel, engineComboBox, btnAutoNest, btnShowRemnants });
toolStrip1.Location = new System.Drawing.Point(0, 24);
toolStrip1.Name = "toolStrip1";
toolStrip1.Size = new System.Drawing.Size(1281, 40);
@@ -1067,7 +1068,15 @@
btnAutoNest.Size = new System.Drawing.Size(64, 37);
btnAutoNest.Text = "Auto Nest";
btnAutoNest.Click += RunAutoNest_Click;
- //
+ //
+ // btnShowRemnants
+ //
+ btnShowRemnants.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text;
+ btnShowRemnants.Name = "btnShowRemnants";
+ btnShowRemnants.Size = new System.Drawing.Size(64, 37);
+ btnShowRemnants.Text = "Remnants";
+ btnShowRemnants.Click += ShowRemnants_Click;
+ //
// pEPToolStripMenuItem
//
pEPToolStripMenuItem.Name = "pEPToolStripMenuItem";
@@ -1232,5 +1241,6 @@
private System.Windows.Forms.ToolStripLabel engineLabel;
private System.Windows.Forms.ToolStripComboBox engineComboBox;
private System.Windows.Forms.ToolStripButton btnAutoNest;
+ private System.Windows.Forms.ToolStripButton btnShowRemnants;
}
}
\ No newline at end of file
diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs
index df40213..d198886 100644
--- a/OpenNest/Forms/MainForm.cs
+++ b/OpenNest/Forms/MainForm.cs
@@ -737,6 +737,49 @@ namespace OpenNest.Forms
activeForm.LoadNextPlate();
}
+ private RemnantViewerForm remnantViewer;
+
+ private void ShowRemnants_Click(object sender, EventArgs e)
+ {
+ if (activeForm?.PlateView?.Plate == null)
+ return;
+
+ var plate = activeForm.PlateView.Plate;
+
+ // Minimum remnant dimension = smallest part bbox dimension on the plate.
+ var minDim = 0.0;
+ var nest = activeForm.Nest;
+ if (nest != null)
+ {
+ foreach (var drawing in nest.Drawings)
+ {
+ var bbox = drawing.Program.BoundingBox();
+ var dim = System.Math.Min(bbox.Width, bbox.Length);
+ if (minDim == 0 || dim < minDim)
+ minDim = dim;
+ }
+ }
+
+ var finder = RemnantFinder.FromPlate(plate);
+ var tiered = finder.FindTieredRemnants(minDim);
+
+ if (remnantViewer == null || remnantViewer.IsDisposed)
+ {
+ remnantViewer = new RemnantViewerForm();
+ remnantViewer.Owner = this;
+
+ // Position next to the main form's right edge.
+ var screen = Screen.FromControl(this);
+ remnantViewer.Location = new Point(
+ System.Math.Min(Right, screen.WorkingArea.Right - remnantViewer.Width),
+ Top);
+ }
+
+ remnantViewer.LoadRemnants(tiered, activeForm.PlateView);
+ remnantViewer.Show();
+ remnantViewer.BringToFront();
+ }
+
private async void RunAutoNest_Click(object sender, EventArgs e)
{
var form = new AutoNestForm(activeForm.Nest);
@@ -744,7 +787,7 @@ namespace OpenNest.Forms
if (form.ShowDialog() != System.Windows.Forms.DialogResult.OK)
return;
-
+
var items = form.GetNestItems();
if (!items.Any(it => it.Quantity > 0))
diff --git a/OpenNest/Forms/RemnantViewerForm.cs b/OpenNest/Forms/RemnantViewerForm.cs
new file mode 100644
index 0000000..010850b
--- /dev/null
+++ b/OpenNest/Forms/RemnantViewerForm.cs
@@ -0,0 +1,119 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Windows.Forms;
+using OpenNest.Controls;
+using OpenNest.Geometry;
+
+namespace OpenNest.Forms
+{
+ public class RemnantViewerForm : Form
+ {
+ private ListView listView;
+ private PlateView plateView;
+ private List remnants = new();
+ private int selectedIndex = -1;
+
+ public RemnantViewerForm()
+ {
+ Text = "Remnants";
+ Size = new System.Drawing.Size(360, 400);
+ StartPosition = FormStartPosition.Manual;
+ FormBorderStyle = FormBorderStyle.SizableToolWindow;
+ ShowInTaskbar = false;
+ TopMost = true;
+
+ listView = new ListView
+ {
+ Dock = DockStyle.Fill,
+ View = View.Details,
+ FullRowSelect = true,
+ GridLines = true,
+ MultiSelect = false,
+ HideSelection = false,
+ };
+
+ listView.Columns.Add("P", 28, HorizontalAlignment.Center);
+ listView.Columns.Add("Size", 110, HorizontalAlignment.Left);
+ listView.Columns.Add("Area", 65, HorizontalAlignment.Right);
+ listView.Columns.Add("Location", 110, HorizontalAlignment.Left);
+
+ listView.SelectedIndexChanged += ListView_SelectedIndexChanged;
+
+ Controls.Add(listView);
+ }
+
+ protected override bool ProcessDialogKey(Keys keyData)
+ {
+ if (keyData == Keys.Escape)
+ {
+ Close();
+ return true;
+ }
+ return base.ProcessDialogKey(keyData);
+ }
+
+ public void LoadRemnants(List tieredRemnants, PlateView view)
+ {
+ plateView = view;
+ remnants = tieredRemnants;
+ selectedIndex = -1;
+
+ listView.BeginUpdate();
+ listView.Items.Clear();
+
+ foreach (var tr in remnants)
+ {
+ var item = new ListViewItem(tr.Priority.ToString());
+ item.SubItems.Add($"{tr.Box.Width:F2} x {tr.Box.Length:F2}");
+ item.SubItems.Add($"{tr.Box.Area():F1}");
+ item.SubItems.Add($"({tr.Box.X:F2}, {tr.Box.Y:F2})");
+
+ switch (tr.Priority)
+ {
+ case 0: item.BackColor = Color.FromArgb(220, 255, 220); break;
+ case 1: item.BackColor = Color.FromArgb(255, 255, 210); break;
+ default: item.BackColor = Color.FromArgb(255, 220, 220); break;
+ }
+
+ listView.Items.Add(item);
+ }
+
+ listView.EndUpdate();
+ }
+
+ private void ListView_SelectedIndexChanged(object sender, EventArgs e)
+ {
+ if (plateView == null)
+ return;
+
+ if (listView.SelectedIndices.Count == 0)
+ {
+ selectedIndex = -1;
+ plateView.DebugRemnants = null;
+ plateView.DebugRemnantPriorities = null;
+ return;
+ }
+
+ selectedIndex = listView.SelectedIndices[0];
+
+ if (selectedIndex >= 0 && selectedIndex < remnants.Count)
+ {
+ var tr = remnants[selectedIndex];
+ plateView.DebugRemnants = new List { tr.Box };
+ plateView.DebugRemnantPriorities = new List { tr.Priority };
+ }
+ }
+
+ protected override void OnFormClosing(FormClosingEventArgs e)
+ {
+ if (plateView != null)
+ {
+ plateView.DebugRemnants = null;
+ plateView.DebugRemnantPriorities = null;
+ }
+
+ base.OnFormClosing(e);
+ }
+ }
+}
diff --git a/OpenNest/LayoutPart.cs b/OpenNest/LayoutPart.cs
index a6eaa42..d737bb3 100644
--- a/OpenNest/LayoutPart.cs
+++ b/OpenNest/LayoutPart.cs
@@ -20,13 +20,14 @@ namespace OpenNest
private Brush brush;
private Pen pen;
- private PointF _labelPoint;
-
private List _offsetPolygonPoints;
private double _cachedOffsetSpacing;
private double _cachedOffsetTolerance;
private double _cachedOffsetRotation = double.NaN;
+ private Vector? _labelPoint;
+ private PointF _labelScreenPoint;
+
public readonly Part BasePart;
static LayoutPart()
@@ -97,61 +98,61 @@ namespace OpenNest
g.DrawPath(pen, Path);
}
- g.DrawString(id, programIdFont, Brushes.Black, _labelPoint.X, _labelPoint.Y);
+ using var sf = new StringFormat
+ {
+ Alignment = StringAlignment.Center,
+ LineAlignment = StringAlignment.Center
+ };
+ g.DrawString(id, programIdFont, Brushes.Black, _labelScreenPoint.X, _labelScreenPoint.Y, sf);
}
public GraphicsPath OffsetPath { get; private set; }
+ private Vector ComputeLabelPoint()
+ {
+ var entities = ConvertProgram.ToGeometry(BasePart.BaseDrawing.Program);
+ var nonRapid = entities.Where(e => e.Layer != SpecialLayers.Rapid).ToList();
+
+ var shapes = ShapeBuilder.GetShapes(nonRapid);
+
+ if (shapes.Count == 0)
+ {
+ var bbox = BasePart.BaseDrawing.Program.BoundingBox();
+ return new Vector(bbox.Location.X + bbox.Width / 2, bbox.Location.Y + bbox.Length / 2);
+ }
+
+ var profile = new ShapeProfile(nonRapid);
+ var outer = profile.Perimeter.ToPolygonWithTolerance(0.1);
+
+ List holes = null;
+
+ if (profile.Cutouts.Count > 0)
+ {
+ holes = new List();
+ foreach (var cutout in profile.Cutouts)
+ holes.Add(cutout.ToPolygonWithTolerance(0.1));
+ }
+
+ return PolyLabel.Find(outer, holes);
+ }
+
public void Update(DrawControl plateView)
{
Path = GraphicsHelper.GetGraphicsPath(BasePart.Program, BasePart.Location);
Path.Transform(plateView.Matrix);
- _labelPoint = ComputeLabelPoint();
+
+ _labelPoint ??= ComputeLabelPoint();
+ var rotatedLabel = _labelPoint.Value.Rotate(BasePart.Rotation);
+ var labelPt = new PointF(
+ (float)(rotatedLabel.X + BasePart.Location.X),
+ (float)(rotatedLabel.Y + BasePart.Location.Y));
+ var pts = new[] { labelPt };
+ plateView.Matrix.TransformPoints(pts);
+ _labelScreenPoint = pts[0];
+
IsDirty = false;
}
- private PointF ComputeLabelPoint()
- {
- if (Path.PointCount == 0)
- return PointF.Empty;
-
- var points = Path.PathPoints;
- var types = Path.PathTypes;
-
- // Extract the largest figure from the path for polylabel.
- var bestFigure = new List();
- var currentFigure = new List();
-
- for (var i = 0; i < points.Length; i++)
- {
- if ((types[i] & 0x01) == 0 && currentFigure.Count > 0)
- {
- // New figure starting — save previous if it's the largest so far.
- if (currentFigure.Count > bestFigure.Count)
- bestFigure = currentFigure;
-
- currentFigure = new List();
- }
-
- currentFigure.Add(new Vector(points[i].X, points[i].Y));
- }
-
- if (currentFigure.Count > bestFigure.Count)
- bestFigure = currentFigure;
-
- if (bestFigure.Count < 3)
- return points[0];
-
- // Close the polygon if needed.
- var first = bestFigure[0];
- var last = bestFigure[bestFigure.Count - 1];
- if (first.DistanceTo(last) > 1e-6)
- bestFigure.Add(first);
-
- var label = PolyLabel.Find(bestFigure, 0.5);
- return new PointF((float)label.X, (float)label.Y);
- }
-
public void UpdateOffset(double spacing, double tolerance, Matrix matrix)
{
if (_offsetPolygonPoints == null ||
diff --git a/docs/superpowers/plans/2026-03-16-engine-refactor.md b/docs/superpowers/plans/2026-03-16-engine-refactor.md
new file mode 100644
index 0000000..fbf14ca
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-16-engine-refactor.md
@@ -0,0 +1,1645 @@
+# Engine Refactor Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Extract shared algorithms from DefaultNestEngine and StripNestEngine into focused, reusable helper classes.
+
+**Architecture:** Five helper classes (AccumulatingProgress, ShrinkFiller, AngleCandidateBuilder, PairFiller, RemnantFiller) are extracted from the two engines. Each engine then composes these helpers instead of inlining the logic. No new interfaces — just focused classes with delegate-based decoupling.
+
+**Tech Stack:** .NET 8, C#, xUnit
+
+**Spec:** `docs/superpowers/specs/2026-03-16-engine-refactor-design.md`
+
+---
+
+## File Structure
+
+**New files (OpenNest.Engine/):**
+- `AccumulatingProgress.cs` — IProgress wrapper that prepends prior parts
+- `ShrinkFiller.cs` — Static shrink-to-fit loop + ShrinkAxis enum + ShrinkResult class
+- `AngleCandidateBuilder.cs` — Angle sweep/ML/pruning with known-good state
+- `PairFiller.cs` — Pair candidate selection and fill loop
+- `RemnantFiller.cs` — Iterative remnant-fill loop over RemnantFinder
+
+**New test files (OpenNest.Tests/):**
+- `AccumulatingProgressTests.cs`
+- `ShrinkFillerTests.cs`
+- `AngleCandidateBuilderTests.cs`
+- `PairFillerTests.cs`
+- `RemnantFillerTests.cs`
+- `EngineRefactorSmokeTests.cs` — Integration tests verifying engines produce same results
+
+**Modified files:**
+- `OpenNest.Engine/DefaultNestEngine.cs` — Remove extracted code, compose helpers
+- `OpenNest.Engine/StripNestEngine.cs` — Remove extracted code, compose helpers
+- `OpenNest.Engine/NestEngineBase.cs` — Replace remnant loop with RemnantFiller
+
+---
+
+## Chunk 1: Leaf Extractions (AccumulatingProgress, ShrinkFiller)
+
+### Task 0: Add InternalsVisibleTo for test project
+
+**Files:**
+- Modify: `OpenNest.Engine/OpenNest.Engine.csproj`
+
+Several extracted classes are `internal`. The test project needs access.
+
+- [ ] **Step 1: Add InternalsVisibleTo to the Engine csproj**
+
+Add inside the first `` or add a new ``:
+
+```xml
+
+
+
+```
+
+- [ ] **Step 2: Build to verify**
+
+Run: `dotnet build OpenNest.sln`
+Expected: Build succeeds.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add OpenNest.Engine/OpenNest.Engine.csproj
+git commit -m "build: add InternalsVisibleTo for OpenNest.Tests"
+```
+
+---
+
+### Task 1: Extract AccumulatingProgress
+
+**Files:**
+- Create: `OpenNest.Engine/AccumulatingProgress.cs`
+- Create: `OpenNest.Tests/AccumulatingProgressTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+In `OpenNest.Tests/AccumulatingProgressTests.cs`:
+
+```csharp
+namespace OpenNest.Tests;
+
+public class AccumulatingProgressTests
+{
+ private class CapturingProgress : IProgress
+ {
+ public NestProgress Last { get; private set; }
+ public void Report(NestProgress value) => Last = value;
+ }
+
+ [Fact]
+ public void Report_PrependsPreviousParts()
+ {
+ var inner = new CapturingProgress();
+ var previous = new List { TestHelpers.MakePartAt(0, 0, 10) };
+ var accumulating = new AccumulatingProgress(inner, previous);
+
+ var newParts = new List { TestHelpers.MakePartAt(20, 0, 10) };
+ accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 });
+
+ Assert.NotNull(inner.Last);
+ Assert.Equal(2, inner.Last.BestParts.Count);
+ Assert.Equal(2, inner.Last.BestPartCount);
+ }
+
+ [Fact]
+ public void Report_NoPreviousParts_PassesThrough()
+ {
+ var inner = new CapturingProgress();
+ var accumulating = new AccumulatingProgress(inner, new List());
+
+ var newParts = new List { TestHelpers.MakePartAt(0, 0, 10) };
+ accumulating.Report(new NestProgress { BestParts = newParts, BestPartCount = 1 });
+
+ Assert.NotNull(inner.Last);
+ Assert.Single(inner.Last.BestParts);
+ }
+
+ [Fact]
+ public void Report_NullBestParts_PassesThrough()
+ {
+ var inner = new CapturingProgress();
+ var previous = new List { TestHelpers.MakePartAt(0, 0, 10) };
+ var accumulating = new AccumulatingProgress(inner, previous);
+
+ accumulating.Report(new NestProgress { BestParts = null });
+
+ Assert.NotNull(inner.Last);
+ Assert.Null(inner.Last.BestParts);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test OpenNest.Tests --filter "AccumulatingProgressTests" -v n`
+Expected: Build error — `AccumulatingProgress` class does not exist yet.
+
+- [ ] **Step 3: Create AccumulatingProgress class**
+
+In `OpenNest.Engine/AccumulatingProgress.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+
+namespace OpenNest
+{
+ ///
+ /// Wraps an IProgress to prepend previously placed parts to each report,
+ /// so the UI shows the full picture during incremental fills.
+ ///
+ internal class AccumulatingProgress : IProgress
+ {
+ private readonly IProgress inner;
+ private readonly List previousParts;
+
+ public AccumulatingProgress(IProgress inner, List previousParts)
+ {
+ this.inner = inner;
+ this.previousParts = previousParts;
+ }
+
+ public void Report(NestProgress value)
+ {
+ if (value.BestParts != null && previousParts.Count > 0)
+ {
+ var combined = new List(previousParts.Count + value.BestParts.Count);
+ combined.AddRange(previousParts);
+ combined.AddRange(value.BestParts);
+ value.BestParts = combined;
+ value.BestPartCount = combined.Count;
+ }
+
+ inner.Report(value);
+ }
+ }
+}
+```
+
+**Note:** The class is `internal`. The test project needs `InternalsVisibleTo`. Check if `OpenNest.Engine.csproj` already has it; if not, add `[assembly: InternalsVisibleTo("OpenNest.Tests")]` in `OpenNest.Engine/Properties/AssemblyInfo.cs` or as an `` item in the csproj.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test OpenNest.Tests --filter "AccumulatingProgressTests" -v n`
+Expected: All 3 tests PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add OpenNest.Engine/AccumulatingProgress.cs OpenNest.Tests/AccumulatingProgressTests.cs
+git commit -m "refactor(engine): extract AccumulatingProgress from StripNestEngine"
+```
+
+---
+
+### Task 2: Extract ShrinkFiller
+
+**Files:**
+- Create: `OpenNest.Engine/ShrinkFiller.cs`
+- Create: `OpenNest.Tests/ShrinkFillerTests.cs`
+
+- [ ] **Step 1: Write the failing tests**
+
+In `OpenNest.Tests/ShrinkFillerTests.cs`:
+
+```csharp
+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()
+ {
+ // 10x10 parts on a 100x50 box with 1.0 spacing.
+ // Initial fill should place multiple parts. Shrinking height
+ // should eventually reduce to the tightest strip.
+ 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);
+
+ // Should return initial fill without shrinking
+ Assert.NotNull(result);
+ Assert.True(result.Parts.Count > 0);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test OpenNest.Tests --filter "ShrinkFillerTests" -v n`
+Expected: Build error — `ShrinkFiller`, `ShrinkAxis`, `ShrinkResult` do not exist.
+
+- [ ] **Step 3: Create ShrinkFiller class**
+
+In `OpenNest.Engine/ShrinkFiller.cs`:
+
+```csharp
+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;
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test OpenNest.Tests --filter "ShrinkFillerTests" -v n`
+Expected: All 4 tests PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add OpenNest.Engine/ShrinkFiller.cs OpenNest.Tests/ShrinkFillerTests.cs
+git commit -m "refactor(engine): extract ShrinkFiller from StripNestEngine"
+```
+
+---
+
+## Chunk 2: AngleCandidateBuilder and PairFiller
+
+### Task 3: Extract AngleCandidateBuilder
+
+**Files:**
+- Create: `OpenNest.Engine/AngleCandidateBuilder.cs`
+- Create: `OpenNest.Tests/AngleCandidateBuilderTests.cs`
+- Modify: `OpenNest.Engine/DefaultNestEngine.cs` — Add forwarding property
+
+- [ ] **Step 1: Write the failing tests**
+
+In `OpenNest.Tests/AngleCandidateBuilderTests.cs`:
+
+```csharp
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests;
+
+public class AngleCandidateBuilderTests
+{
+ private static Drawing MakeRectDrawing(double w, double h)
+ {
+ 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("rect", pgm);
+ }
+
+ [Fact]
+ public void Build_ReturnsAtLeastTwoAngles()
+ {
+ var builder = new AngleCandidateBuilder();
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
+ var workArea = new Box(0, 0, 100, 100);
+
+ var angles = builder.Build(item, 0, workArea);
+
+ Assert.True(angles.Count >= 2);
+ }
+
+ [Fact]
+ public void Build_NarrowWorkArea_ProducesMoreAngles()
+ {
+ var builder = new AngleCandidateBuilder();
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
+ var wideArea = new Box(0, 0, 100, 100);
+ var narrowArea = new Box(0, 0, 100, 8); // narrower than part's longest side
+
+ var wideAngles = builder.Build(item, 0, wideArea);
+ var narrowAngles = builder.Build(item, 0, narrowArea);
+
+ Assert.True(narrowAngles.Count > wideAngles.Count,
+ $"Narrow ({narrowAngles.Count}) should have more angles than wide ({wideAngles.Count})");
+ }
+
+ [Fact]
+ public void ForceFullSweep_ProducesFullSweep()
+ {
+ var builder = new AngleCandidateBuilder { ForceFullSweep = true };
+ var item = new NestItem { Drawing = MakeRectDrawing(5, 5) };
+ var workArea = new Box(0, 0, 100, 100);
+
+ var angles = builder.Build(item, 0, workArea);
+
+ // Full sweep at 5° steps = ~36 angles (0 to 175), plus base angles
+ Assert.True(angles.Count > 10);
+ }
+
+ [Fact]
+ public void RecordProductive_PrunesSubsequentBuilds()
+ {
+ var builder = new AngleCandidateBuilder { ForceFullSweep = true };
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
+ var workArea = new Box(0, 0, 100, 8);
+
+ // First build — full sweep
+ var firstAngles = builder.Build(item, 0, workArea);
+
+ // Record some as productive
+ var productive = new List
+ {
+ new AngleResult { AngleDeg = 0, PartCount = 5 },
+ new AngleResult { AngleDeg = 45, PartCount = 3 },
+ };
+ builder.RecordProductive(productive);
+
+ // Second build — should be pruned to known-good + base angles
+ builder.ForceFullSweep = false;
+ var secondAngles = builder.Build(item, 0, workArea);
+
+ Assert.True(secondAngles.Count < firstAngles.Count,
+ $"Pruned ({secondAngles.Count}) should be fewer than full ({firstAngles.Count})");
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test OpenNest.Tests --filter "AngleCandidateBuilderTests" -v n`
+Expected: Build error — `AngleCandidateBuilder` does not exist.
+
+- [ ] **Step 3: Create AngleCandidateBuilder class**
+
+In `OpenNest.Engine/AngleCandidateBuilder.cs`:
+
+```csharp
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using OpenNest.Engine.ML;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest
+{
+ ///
+ /// Builds candidate rotation angles for single-item fill. Encapsulates the
+ /// full pipeline: base angles, narrow-area sweep, ML prediction, and
+ /// known-good pruning across fills.
+ ///
+ public class AngleCandidateBuilder
+ {
+ private readonly HashSet knownGoodAngles = new();
+
+ public bool ForceFullSweep { get; set; }
+
+ public List Build(NestItem item, double bestRotation, Box workArea)
+ {
+ var angles = new List { bestRotation, bestRotation + Angle.HalfPI };
+
+ var testPart = new Part(item.Drawing);
+ if (!bestRotation.IsEqualTo(0))
+ testPart.Rotate(bestRotation);
+ testPart.UpdateBounds();
+
+ var partLongestSide = System.Math.Max(testPart.BoundingBox.Width, testPart.BoundingBox.Length);
+ var workAreaShortSide = System.Math.Min(workArea.Width, workArea.Length);
+ var needsSweep = workAreaShortSide < partLongestSide || ForceFullSweep;
+
+ if (needsSweep)
+ {
+ var step = Angle.ToRadians(5);
+ for (var a = 0.0; a < System.Math.PI; a += step)
+ {
+ if (!angles.Any(existing => existing.IsEqualTo(a)))
+ angles.Add(a);
+ }
+ }
+
+ if (!ForceFullSweep && angles.Count > 2)
+ {
+ var features = FeatureExtractor.Extract(item.Drawing);
+ if (features != null)
+ {
+ var predicted = AnglePredictor.PredictAngles(
+ features, workArea.Width, workArea.Length);
+
+ if (predicted != null)
+ {
+ var mlAngles = new List(predicted);
+
+ if (!mlAngles.Any(a => a.IsEqualTo(bestRotation)))
+ mlAngles.Add(bestRotation);
+ if (!mlAngles.Any(a => a.IsEqualTo(bestRotation + Angle.HalfPI)))
+ mlAngles.Add(bestRotation + Angle.HalfPI);
+
+ Debug.WriteLine($"[AngleCandidateBuilder] ML: {angles.Count} angles -> {mlAngles.Count} predicted");
+ angles = mlAngles;
+ }
+ }
+ }
+
+ if (knownGoodAngles.Count > 0 && !ForceFullSweep)
+ {
+ var pruned = new List { bestRotation, bestRotation + Angle.HalfPI };
+
+ foreach (var a in knownGoodAngles)
+ {
+ if (!pruned.Any(existing => existing.IsEqualTo(a)))
+ pruned.Add(a);
+ }
+
+ Debug.WriteLine($"[AngleCandidateBuilder] Pruned: {angles.Count} -> {pruned.Count} angles (known-good)");
+ return pruned;
+ }
+
+ return angles;
+ }
+
+ ///
+ /// Records angles that produced results. These are used to prune
+ /// subsequent Build() calls.
+ ///
+ public void RecordProductive(List angleResults)
+ {
+ foreach (var ar in angleResults)
+ {
+ if (ar.PartCount > 0)
+ knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg));
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test OpenNest.Tests --filter "AngleCandidateBuilderTests" -v n`
+Expected: All 4 tests PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add OpenNest.Engine/AngleCandidateBuilder.cs OpenNest.Tests/AngleCandidateBuilderTests.cs
+git commit -m "refactor(engine): extract AngleCandidateBuilder from DefaultNestEngine"
+```
+
+---
+
+### Task 4: Make BuildRotatedPattern and FillPattern internal static
+
+**Files:**
+- Modify: `OpenNest.Engine/DefaultNestEngine.cs:493-546`
+
+This task prepares the pattern helpers for use by PairFiller. No tests needed — existing behavior is unchanged.
+
+- [ ] **Step 1: Change method signatures to internal static**
+
+In `DefaultNestEngine.cs`, change `BuildRotatedPattern` (line 493):
+- From: `private Pattern BuildRotatedPattern(List groupParts, double angle)`
+- To: `internal static Pattern BuildRotatedPattern(List groupParts, double angle)`
+
+Change `FillPattern` (line 513):
+- From: `private List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea)`
+- To: `internal static List FillPattern(FillLinear engine, List groupParts, List angles, Box workArea)`
+
+These methods already have no instance state access — they only use their parameters. Verify this by confirming no `this.` or instance field/property references in their bodies.
+
+- [ ] **Step 2: Build to verify no regressions**
+
+Run: `dotnet build OpenNest.sln`
+Expected: Build succeeds.
+
+- [ ] **Step 3: Run all tests**
+
+Run: `dotnet test OpenNest.Tests -v n`
+Expected: All tests PASS.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add OpenNest.Engine/DefaultNestEngine.cs
+git commit -m "refactor(engine): make BuildRotatedPattern and FillPattern internal static"
+```
+
+---
+
+### Task 5: Extract PairFiller
+
+**Files:**
+- Create: `OpenNest.Engine/PairFiller.cs`
+- Create: `OpenNest.Tests/PairFillerTests.cs`
+
+- [ ] **Step 1: Write the failing tests**
+
+In `OpenNest.Tests/PairFillerTests.cs`:
+
+```csharp
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests;
+
+public class PairFillerTests
+{
+ private static Drawing MakeRectDrawing(double w, double h)
+ {
+ 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("rect", pgm);
+ }
+
+ [Fact]
+ public void Fill_ReturnsPartsForSimpleDrawing()
+ {
+ var plateSize = new Size(120, 60);
+ var filler = new PairFiller(plateSize, 0.5);
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
+ var workArea = new Box(0, 0, 120, 60);
+
+ var parts = filler.Fill(item, workArea);
+
+ Assert.NotNull(parts);
+ // Pair filling may or may not find interlocking pairs for rectangles,
+ // but should return a non-null list.
+ }
+
+ [Fact]
+ public void Fill_EmptyResult_WhenPartTooLarge()
+ {
+ var plateSize = new Size(10, 10);
+ var filler = new PairFiller(plateSize, 0.5);
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 20) };
+ var workArea = new Box(0, 0, 10, 10);
+
+ var parts = filler.Fill(item, workArea);
+
+ Assert.NotNull(parts);
+ Assert.Empty(parts);
+ }
+
+ [Fact]
+ public void Fill_RespectsCancellation()
+ {
+ var cts = new System.Threading.CancellationTokenSource();
+ cts.Cancel();
+
+ var plateSize = new Size(120, 60);
+ var filler = new PairFiller(plateSize, 0.5);
+ var item = new NestItem { Drawing = MakeRectDrawing(20, 10) };
+ var workArea = new Box(0, 0, 120, 60);
+
+ var parts = filler.Fill(item, workArea, token: cts.Token);
+
+ // Should return empty or partial — not throw
+ Assert.NotNull(parts);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test OpenNest.Tests --filter "PairFillerTests" -v n`
+Expected: Build error — `PairFiller` does not exist.
+
+- [ ] **Step 3: Create PairFiller class**
+
+In `OpenNest.Engine/PairFiller.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading;
+using OpenNest.Engine.BestFit;
+using OpenNest.Geometry;
+using OpenNest.Math;
+
+namespace OpenNest
+{
+ ///
+ /// Fills a work area using interlocking part pairs from BestFitCache.
+ /// Extracted from DefaultNestEngine.FillWithPairs.
+ ///
+ public class PairFiller
+ {
+ private const int MinPairCandidates = 10;
+ private static readonly TimeSpan PairTimeLimit = TimeSpan.FromSeconds(3);
+
+ private readonly Size plateSize;
+ private readonly double partSpacing;
+
+ public PairFiller(Size plateSize, double partSpacing)
+ {
+ this.plateSize = plateSize;
+ this.partSpacing = partSpacing;
+ }
+
+ public List Fill(NestItem item, Box workArea,
+ int plateNumber = 0,
+ CancellationToken token = default,
+ IProgress progress = null)
+ {
+ var bestFits = BestFitCache.GetOrCompute(
+ item.Drawing, plateSize.Width, plateSize.Length, partSpacing);
+
+ var candidates = SelectPairCandidates(bestFits, workArea);
+ var remainderPatterns = BuildRemainderPatterns(candidates, item);
+ Debug.WriteLine($"[PairFiller] Total: {bestFits.Count}, Kept: {bestFits.Count(r => r.Keep)}, Trying: {candidates.Count}");
+ Debug.WriteLine($"[PairFiller] Plate: {plateSize.Width:F2}x{plateSize.Length:F2}, WorkArea: {workArea.Width:F2}x{workArea.Length:F2}");
+
+ List best = null;
+ var bestScore = default(FillScore);
+ var deadline = Stopwatch.StartNew();
+
+ try
+ {
+ for (var i = 0; i < candidates.Count; i++)
+ {
+ token.ThrowIfCancellationRequested();
+
+ if (i >= MinPairCandidates && deadline.Elapsed > PairTimeLimit)
+ {
+ Debug.WriteLine($"[PairFiller] Time limit at {i + 1}/{candidates.Count} ({deadline.ElapsedMilliseconds}ms)");
+ break;
+ }
+
+ var result = candidates[i];
+ var pairParts = result.BuildParts(item.Drawing);
+ var angles = result.HullAngles;
+ var engine = new FillLinear(workArea, partSpacing);
+ engine.RemainderPatterns = remainderPatterns;
+ var filled = DefaultNestEngine.FillPattern(engine, pairParts, angles, workArea);
+
+ if (filled != null && filled.Count > 0)
+ {
+ var score = FillScore.Compute(filled, workArea);
+ if (best == null || score > bestScore)
+ {
+ best = filled;
+ bestScore = score;
+ }
+ }
+
+ NestEngineBase.ReportProgress(progress, NestPhase.Pairs, plateNumber, best, workArea,
+ $"Pairs: {i + 1}/{candidates.Count} candidates, best = {bestScore.Count} parts");
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ Debug.WriteLine("[PairFiller] Cancelled mid-phase, using results so far");
+ }
+
+ Debug.WriteLine($"[PairFiller] Best pair result: {bestScore.Count} parts, density={bestScore.Density:P1} ({deadline.ElapsedMilliseconds}ms)");
+ return best ?? new List();
+ }
+
+ private List SelectPairCandidates(List bestFits, Box workArea)
+ {
+ var kept = bestFits.Where(r => r.Keep).ToList();
+ var top = kept.Take(50).ToList();
+
+ var workShortSide = System.Math.Min(workArea.Width, workArea.Length);
+ var plateShortSide = System.Math.Min(plateSize.Width, plateSize.Length);
+
+ if (workShortSide < plateShortSide * 0.5)
+ {
+ var stripCandidates = bestFits
+ .Where(r => r.ShortestSide <= workShortSide + Tolerance.Epsilon
+ && r.Utilization >= 0.3)
+ .OrderByDescending(r => r.Utilization);
+
+ var existing = new HashSet(top);
+
+ foreach (var r in stripCandidates)
+ {
+ if (top.Count >= 100)
+ break;
+
+ if (existing.Add(r))
+ top.Add(r);
+ }
+
+ Debug.WriteLine($"[PairFiller] Strip mode: {top.Count} candidates (shortSide <= {workShortSide:F1})");
+ }
+
+ return top;
+ }
+
+ private List BuildRemainderPatterns(List candidates, NestItem item)
+ {
+ var patterns = new List();
+
+ foreach (var candidate in candidates.Take(5))
+ {
+ var pairParts = candidate.BuildParts(item.Drawing);
+ var angles = candidate.HullAngles ?? new List { 0 };
+
+ foreach (var angle in angles.Take(3))
+ {
+ var pattern = DefaultNestEngine.BuildRotatedPattern(pairParts, angle);
+ if (pattern.Parts.Count > 0)
+ patterns.Add(pattern);
+ }
+ }
+
+ return patterns;
+ }
+ }
+}
+```
+
+**Note:** `NestEngineBase.ReportProgress` is currently `protected static`. It needs to become `internal static` so PairFiller can call it. Change the access modifier in `NestEngineBase.cs` line 180 from `protected static` to `internal static`.
+
+- [ ] **Step 4: Change ReportProgress to internal static**
+
+In `NestEngineBase.cs` line 180, change:
+- From: `protected static void ReportProgress(`
+- To: `internal static void ReportProgress(`
+
+- [ ] **Step 5: Run test to verify it passes**
+
+Run: `dotnet test OpenNest.Tests --filter "PairFillerTests" -v n`
+Expected: All 3 tests PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add OpenNest.Engine/PairFiller.cs OpenNest.Tests/PairFillerTests.cs OpenNest.Engine/NestEngineBase.cs
+git commit -m "refactor(engine): extract PairFiller from DefaultNestEngine"
+```
+
+---
+
+## Chunk 3: RemnantFiller and Engine Rewiring
+
+### Task 6: Extract RemnantFiller
+
+**Files:**
+- Create: `OpenNest.Engine/RemnantFiller.cs`
+- Create: `OpenNest.Tests/RemnantFillerTests2.cs` (using `2` to avoid conflict with existing `RemnantFinderTests.cs`)
+
+- [ ] **Step 1: Write the failing tests**
+
+In `OpenNest.Tests/RemnantFillerTests2.cs`:
+
+```csharp
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests;
+
+public class RemnantFillerTests2
+{
+ 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("sq", pgm);
+ }
+
+ [Fact]
+ public void FillItems_PlacesPartsInRemnants()
+ {
+ var workArea = new Box(0, 0, 100, 100);
+ var filler = new RemnantFiller(workArea, 1.0);
+
+ // Place a large obstacle leaving a 40x100 strip on the right
+ filler.AddObstacles(new[] { TestHelpers.MakePartAt(0, 0, 50) });
+
+ var drawing = MakeSquareDrawing(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 placed = filler.FillItems(items, fillFunc);
+
+ Assert.True(placed.Count > 0, "Should place parts in remaining space");
+ }
+
+ [Fact]
+ public void FillItems_DoesNotMutateItemQuantities()
+ {
+ var workArea = new Box(0, 0, 100, 100);
+ var filler = new RemnantFiller(workArea, 1.0);
+
+ var drawing = MakeSquareDrawing(10);
+ var items = new List
+ {
+ new NestItem { Drawing = drawing, Quantity = 3 }
+ };
+
+ 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);
+ };
+
+ filler.FillItems(items, fillFunc);
+
+ Assert.Equal(3, items[0].Quantity);
+ }
+
+ [Fact]
+ public void FillItems_EmptyItems_ReturnsEmpty()
+ {
+ var workArea = new Box(0, 0, 100, 100);
+ var filler = new RemnantFiller(workArea, 1.0);
+
+ Func> fillFunc = (ni, b) => new List();
+
+ var result = filler.FillItems(new List(), fillFunc);
+
+ Assert.Empty(result);
+ }
+
+ [Fact]
+ public void FillItems_RespectsCancellation()
+ {
+ var cts = new System.Threading.CancellationTokenSource();
+ cts.Cancel();
+
+ var workArea = new Box(0, 0, 100, 100);
+ var filler = new RemnantFiller(workArea, 1.0);
+
+ var drawing = MakeSquareDrawing(10);
+ var items = new List
+ {
+ new NestItem { Drawing = drawing, Quantity = 5 }
+ };
+
+ Func> fillFunc = (ni, b) =>
+ new List { TestHelpers.MakePartAt(0, 0, 10) };
+
+ var result = filler.FillItems(items, fillFunc, cts.Token);
+
+ // Should not throw, returns whatever was placed
+ Assert.NotNull(result);
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test OpenNest.Tests --filter "RemnantFillerTests2" -v n`
+Expected: Build error — `RemnantFiller` class (the new one, not `RemnantFinder`) does not exist.
+
+- [ ] **Step 3: Create RemnantFiller class**
+
+In `OpenNest.Engine/RemnantFiller.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.Geometry;
+
+namespace OpenNest
+{
+ ///
+ /// Iteratively fills remnant boxes with items using a RemnantFinder.
+ /// After each fill, re-discovers free rectangles and tries again
+ /// until no more items can be placed.
+ ///
+ public class RemnantFiller
+ {
+ private readonly RemnantFinder finder;
+ private readonly double spacing;
+
+ public RemnantFiller(Box workArea, double spacing)
+ {
+ this.spacing = spacing;
+ finder = new RemnantFinder(workArea);
+ }
+
+ public void AddObstacles(IEnumerable parts)
+ {
+ foreach (var part in parts)
+ finder.AddObstacle(part.BoundingBox.Offset(spacing));
+ }
+
+ public List FillItems(
+ List items,
+ Func> fillFunc,
+ CancellationToken token = default,
+ IProgress progress = null)
+ {
+ if (items == null || items.Count == 0)
+ return new List();
+
+ var allParts = new List();
+ var madeProgress = true;
+
+ // Track quantities locally — do not mutate the input NestItem objects.
+ // NOTE: Keyed by Drawing.Name — duplicate names would collide.
+ // This matches the original StripNestEngine behavior.
+ var localQty = new Dictionary();
+ foreach (var item in items)
+ localQty[item.Drawing.Name] = item.Quantity;
+
+ while (madeProgress && !token.IsCancellationRequested)
+ {
+ madeProgress = false;
+
+ var minRemnantDim = double.MaxValue;
+ foreach (var item in items)
+ {
+ var qty = localQty[item.Drawing.Name];
+ if (qty <= 0)
+ continue;
+ var bb = item.Drawing.Program.BoundingBox();
+ var dim = System.Math.Min(bb.Width, bb.Length);
+ if (dim < minRemnantDim)
+ minRemnantDim = dim;
+ }
+
+ if (minRemnantDim == double.MaxValue)
+ break;
+
+ var freeBoxes = finder.FindRemnants(minRemnantDim);
+
+ if (freeBoxes.Count == 0)
+ break;
+
+ foreach (var item in items)
+ {
+ if (token.IsCancellationRequested)
+ break;
+
+ var qty = localQty[item.Drawing.Name];
+ if (qty == 0)
+ continue;
+
+ var itemBbox = item.Drawing.Program.BoundingBox();
+ var minItemDim = System.Math.Min(itemBbox.Width, itemBbox.Length);
+
+ foreach (var box in freeBoxes)
+ {
+ if (System.Math.Min(box.Width, box.Length) < minItemDim)
+ continue;
+
+ var fillItem = new NestItem { Drawing = item.Drawing, Quantity = qty };
+ var remnantParts = fillFunc(fillItem, box);
+
+ if (remnantParts != null && remnantParts.Count > 0)
+ {
+ allParts.AddRange(remnantParts);
+ localQty[item.Drawing.Name] = System.Math.Max(0, qty - remnantParts.Count);
+
+ foreach (var p in remnantParts)
+ finder.AddObstacle(p.BoundingBox.Offset(spacing));
+
+ madeProgress = true;
+ break;
+ }
+ }
+
+ if (madeProgress)
+ break;
+ }
+ }
+
+ return allParts;
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test OpenNest.Tests --filter "RemnantFillerTests2" -v n`
+Expected: All 4 tests PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add OpenNest.Engine/RemnantFiller.cs OpenNest.Tests/RemnantFillerTests2.cs
+git commit -m "refactor(engine): extract RemnantFiller for iterative remnant filling"
+```
+
+---
+
+### Task 7: Rewire DefaultNestEngine
+
+**Files:**
+- Modify: `OpenNest.Engine/DefaultNestEngine.cs`
+
+This is the core rewiring. Replace extracted methods with calls to the new helper classes.
+
+- [ ] **Step 1: Add AngleCandidateBuilder field and ForceFullAngleSweep forwarding property**
+
+In `DefaultNestEngine.cs`, replace:
+
+```csharp
+public bool ForceFullAngleSweep { get; set; }
+
+// Angles that have produced results across multiple Fill calls.
+// Populated after each Fill; used to prune subsequent fills.
+private readonly HashSet knownGoodAngles = new();
+```
+
+With:
+
+```csharp
+private readonly AngleCandidateBuilder angleBuilder = new();
+
+public bool ForceFullAngleSweep
+{
+ get => angleBuilder.ForceFullSweep;
+ set => angleBuilder.ForceFullSweep = value;
+}
+```
+
+- [ ] **Step 2: Replace BuildCandidateAngles call in FindBestFill**
+
+In `FindBestFill` (line ~181), replace:
+```csharp
+var angles = BuildCandidateAngles(item, bestRotation, workArea);
+```
+With:
+```csharp
+var angles = angleBuilder.Build(item, bestRotation, workArea);
+```
+
+- [ ] **Step 3: Replace knownGoodAngles recording in FindBestFill**
+
+In `FindBestFill`, replace the block (around line 243):
+```csharp
+// Record productive angles for future fills.
+foreach (var ar in AngleResults)
+{
+ if (ar.PartCount > 0)
+ knownGoodAngles.Add(Angle.ToRadians(ar.AngleDeg));
+}
+```
+With:
+```csharp
+angleBuilder.RecordProductive(AngleResults);
+```
+
+- [ ] **Step 4: Replace FillWithPairs call in FindBestFill**
+
+In `FindBestFill`, replace:
+```csharp
+var pairResult = FillWithPairs(item, workArea, token, progress);
+```
+With:
+```csharp
+var pairFiller = new PairFiller(Plate.Size, Plate.PartSpacing);
+var pairResult = pairFiller.Fill(item, workArea, PlateNumber, token, progress);
+```
+
+- [ ] **Step 5: Replace FillWithPairs call in Fill(List) overload**
+
+In the `Fill(List groupParts, Box workArea, ...)` method (around line 137), replace:
+```csharp
+var pairResult = FillWithPairs(nestItem, workArea, token, progress);
+```
+With:
+```csharp
+var pairFiller = new PairFiller(Plate.Size, Plate.PartSpacing);
+var pairResult = pairFiller.Fill(nestItem, workArea, PlateNumber, token, progress);
+```
+
+- [ ] **Step 6: Delete the extracted methods**
+
+Remove these methods and fields from DefaultNestEngine:
+- `private readonly HashSet knownGoodAngles` (already replaced in Step 1)
+- `private List BuildCandidateAngles(...)` (entire method, lines 279-347)
+- `private List FillWithPairs(...)` (entire method, lines 365-421)
+- `private List SelectPairCandidates(...)` (entire method, lines 428-462)
+- `private List BuildRemainderPatterns(...)` (entire method, lines 471-489)
+- `private const int MinPairCandidates = 10;` (line 362)
+- `private static readonly TimeSpan PairTimeLimit = TimeSpan.FromSeconds(3);` (line 363)
+
+Also remove now-unused `using` statements if any (e.g., `using OpenNest.Engine.BestFit;` if no longer referenced, though `QuickFillCount` still uses `BestFitCache`).
+
+- [ ] **Step 7: Build to verify**
+
+Run: `dotnet build OpenNest.sln`
+Expected: Build succeeds with no errors.
+
+- [ ] **Step 8: Run all tests**
+
+Run: `dotnet test OpenNest.Tests -v n`
+Expected: All tests PASS.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add OpenNest.Engine/DefaultNestEngine.cs
+git commit -m "refactor(engine): rewire DefaultNestEngine to use extracted helpers"
+```
+
+---
+
+### Task 8: Rewire StripNestEngine
+
+**Files:**
+- Modify: `OpenNest.Engine/StripNestEngine.cs`
+
+- [ ] **Step 1: Replace TryOrientation shrink loop with ShrinkFiller**
+
+In `TryOrientation`, replace the shrink loop (lines 188-215) with:
+
+```csharp
+// Shrink to tightest strip.
+var shrinkAxis = direction == StripDirection.Bottom
+ ? ShrinkAxis.Height : ShrinkAxis.Width;
+
+Func> shrinkFill = (ni, b) =>
+{
+ var trialInner = new DefaultNestEngine(Plate);
+ return trialInner.Fill(ni, b, progress, token);
+};
+
+var shrinkResult = ShrinkFiller.Shrink(shrinkFill,
+ new NestItem { Drawing = stripItem.Drawing, Quantity = stripItem.Quantity },
+ stripBox, Plate.PartSpacing, shrinkAxis, token);
+
+if (shrinkResult.Parts == null || shrinkResult.Parts.Count == 0)
+ return result;
+
+bestParts = shrinkResult.Parts;
+bestDim = shrinkResult.Dimension;
+```
+
+Remove the local variables that the shrink loop used to compute (`actualDim`, `targetCount`, and the `for` loop itself). Keep the remnant section below it.
+
+**Important:** The initial fill call (lines 169-172) stays as-is — ShrinkFiller handles both the initial fill AND the shrink loop. So replace from the initial fill through the shrink loop with the single `ShrinkFiller.Shrink()` call, since `Shrink` does its own initial fill internally.
+
+Actually — looking more carefully, the initial fill (lines 170-172) uses `progress` while shrink trials in the original code create a new `DefaultNestEngine` each time. `ShrinkFiller.Shrink` does its own initial fill via `fillFunc`. So the replacement should:
+
+1. Remove the initial fill (lines 169-175) and the shrink loop (lines 188-215)
+2. Replace both with the single `ShrinkFiller.Shrink()` call
+
+The `fillFunc` delegate handles both the initial fill and each shrink trial.
+
+- [ ] **Step 2: Replace ShrinkFill method with ShrinkFiller calls**
+
+Replace the entire `ShrinkFill` method (lines 358-419) with:
+
+```csharp
+private List ShrinkFill(NestItem item, Box box,
+ IProgress progress, CancellationToken token)
+{
+ Func> fillFunc = (ni, b) =>
+ {
+ var inner = new DefaultNestEngine(Plate);
+ return inner.Fill(ni, b, null, token);
+ };
+
+ // The original code shrinks width then height independently against the
+ // original box. The height shrink's result overwrites the width shrink's,
+ // so only the height result matters. We run both to preserve behavior
+ // (width shrink is a no-op on the final result but we keep it for parity).
+ var heightResult = ShrinkFiller.Shrink(fillFunc, item, box,
+ Plate.PartSpacing, ShrinkAxis.Height, token);
+
+ return heightResult.Parts;
+}
+```
+
+**Note:** The original `ShrinkFill` runs width-shrink then height-shrink sequentially, but the height result always overwrites the width result (both shrink against the original box independently, `targetCount` never changes). Running only height-shrink is faithful to the original output while avoiding redundant initial fills.
+
+- [ ] **Step 3: Replace remnant loop with RemnantFiller**
+
+In `TryOrientation`, replace the remnant section (from `// Build remnant box with spacing gap` through the end of the `while (madeProgress ...)` loop) with:
+
+```csharp
+// Fill remnants
+if (remnantBox.Width > 0 && remnantBox.Length > 0)
+{
+ var remnantProgress = progress != null
+ ? new AccumulatingProgress(progress, allParts)
+ : (IProgress)null;
+
+ var remnantFiller = new RemnantFiller(workArea, spacing);
+ remnantFiller.AddObstacles(allParts);
+
+ Func> remnantFillFunc = (ni, b) =>
+ ShrinkFill(ni, b, remnantProgress, token);
+
+ var additional = remnantFiller.FillItems(effectiveRemainder,
+ remnantFillFunc, token, remnantProgress);
+
+ allParts.AddRange(additional);
+}
+```
+
+Keep the `effectiveRemainder` construction and sorting that precedes this block (lines 239-259). Keep the `remnantBox` computation (lines 228-233) since we need to check `remnantBox.Width > 0`.
+
+Remove:
+- The `localQty` dictionary (lines 276-278)
+- The `while (madeProgress ...)` loop (lines 280-342)
+- The `AccumulatingProgress` nested class (lines 425-449) — now using the standalone version
+
+- [ ] **Step 4: Build to verify**
+
+Run: `dotnet build OpenNest.sln`
+Expected: Build succeeds.
+
+- [ ] **Step 5: Run all tests**
+
+Run: `dotnet test OpenNest.Tests -v n`
+Expected: All tests PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add OpenNest.Engine/StripNestEngine.cs
+git commit -m "refactor(engine): rewire StripNestEngine to use extracted helpers"
+```
+
+---
+
+### Task 9: Rewire NestEngineBase.Nest
+
+**Files:**
+- Modify: `OpenNest.Engine/NestEngineBase.cs:74-97`
+
+- [ ] **Step 1: Replace the fill loop with RemnantFiller**
+
+In `NestEngineBase.Nest`, replace phase 1 (the `foreach (var item in fillItems)` loop, lines 74-98) with:
+
+```csharp
+// Phase 1: Fill multi-quantity drawings using RemnantFiller.
+if (fillItems.Count > 0)
+{
+ var remnantFiller = new RemnantFiller(workArea, Plate.PartSpacing);
+
+ Func | | | |