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.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}");
+ }
}