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