From 694d4b28ccf21d296a2b711e2fb5be44db34b1fc Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 1 Oct 2026 07:28:13 -0400 Subject: [PATCH] refactor(geometry): move maximal-rectangle search out of RemnantFinder The histogram search over a free/blocked cell grid never depended on the obstacles being boxes. Move it to OpenNest.Core as MaximalRectangles.FromGrid so other callers can build their own grids; RemnantFinder keeps building its obstacle grid and calls it. No behavior change. --- OpenNest.Core/Geometry/MaximalRectangles.cs | 128 ++++++++++++++++++++ OpenNest.Engine/Fill/RemnantFinder.cs | 108 +---------------- 2 files changed, 134 insertions(+), 102 deletions(-) create mode 100644 OpenNest.Core/Geometry/MaximalRectangles.cs diff --git a/OpenNest.Core/Geometry/MaximalRectangles.cs b/OpenNest.Core/Geometry/MaximalRectangles.cs new file mode 100644 index 0000000..7200056 --- /dev/null +++ b/OpenNest.Core/Geometry/MaximalRectangles.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; + +namespace OpenNest.Geometry; + +/// +/// Maximal empty axis-aligned rectangles: rectangles of free space that cannot grow in any +/// direction. The first result is the largest by area. +/// +public static class MaximalRectangles +{ + /// + /// Finds the maximal rectangles of empty cells in a rectilinear grid, using the histogram + /// method: for each row, a height histogram of consecutive empty cells below it, scanned + /// with a stack. + /// + /// Ascending column boundaries; column c spans xs[c] to xs[c + 1]. + /// Ascending row boundaries; row r spans ys[r] to ys[r + 1]. + /// Free cells, indexed [row, column]. + /// Rectangles narrower than this in either axis are dropped. + /// Rectangles not contained in another result, largest area first. + public static List FromGrid( + IReadOnlyList xs, + IReadOnlyList ys, + bool[,] empty, + double minDimension = 0 + ) + { + var merged = MergeCells(xs, ys, empty); + var sized = FilterBySize(merged, minDimension); + return RemoveDominated(sized); + } + + private static List MergeCells(IReadOnlyList xs, IReadOnlyList ys, bool[,] empty) + { + var rows = empty.GetLength(0); + var cols = empty.GetLength(1); + var height = new int[rows, cols]; + + for (var c = 0; c < cols; c++) + { + for (var r = 0; r < rows; r++) + height[r, c] = empty[r, c] ? (r > 0 ? height[r - 1, c] + 1 : 1) : 0; + } + + var candidates = new List(); + + for (var r = 0; r < rows; r++) + { + var stack = new Stack<(int startCol, int h)>(); + + for (var c = 0; c <= cols; c++) + { + var h = c < cols ? height[r, c] : 0; + var startCol = c; + + while (stack.Count > 0 && stack.Peek().h > h) + { + var top = stack.Pop(); + startCol = top.startCol; + + candidates.Add( + new Box( + xs[top.startCol], + ys[r - top.h + 1], + xs[c] - xs[top.startCol], + ys[r + 1] - ys[r - top.h + 1] + ) + ); + } + + if (h > 0) + stack.Push((startCol, h)); + } + } + + return candidates; + } + + 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; + } +} diff --git a/OpenNest.Engine/Fill/RemnantFinder.cs b/OpenNest.Engine/Fill/RemnantFinder.cs index bd6c3ed..dbc2c72 100644 --- a/OpenNest.Engine/Fill/RemnantFinder.cs +++ b/OpenNest.Engine/Fill/RemnantFinder.cs @@ -56,9 +56,12 @@ namespace OpenNest.Engine.Fill if (grid.Rows <= 0 || grid.Cols <= 0) return new List(); - var merged = MergeCells(grid); - var sized = FilterBySize(merged, minDimension); - var unique = RemoveDominated(sized); + var unique = MaximalRectangles.FromGrid( + grid.XCoords, + grid.YCoords, + grid.Empty, + minDimension + ); SortByEdgeProximity(unique); return unique; } @@ -188,56 +191,6 @@ namespace OpenNest.Engine.Fill 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( @@ -451,54 +404,5 @@ namespace OpenNest.Engine.Fill return new Box(left, bottom, right - left, top - bottom); } - - /// - /// 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) - { - var height = new int[grid.Rows, grid.Cols]; - - for (var c = 0; c < grid.Cols; c++) - { - for (var r = 0; r < grid.Rows; r++) - height[r, c] = grid.Empty[r, c] ? (r > 0 ? height[r - 1, c] + 1 : 1) : 0; - } - - var candidates = new List(); - - for (var r = 0; r < grid.Rows; r++) - { - var stack = new Stack<(int startCol, int h)>(); - - for (var c = 0; c <= grid.Cols; c++) - { - var h = c < grid.Cols ? height[r, c] : 0; - var startCol = c; - - while (stack.Count > 0 && stack.Peek().h > h) - { - var top = stack.Pop(); - startCol = top.startCol; - - 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] - ) - ); - } - - if (h > 0) - stack.Push((startCol, h)); - } - } - - return candidates; - } } }