using System; using System.Collections.Generic; using System.Linq; using Clipper2Lib; 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 maximal axis-aligned rectangles that lie wholly inside a region, such as a cutout /// already shrunk by the part spacing. Rectangles may touch the region's boundary but never /// cross it. /// /// /// The grid has a line through every vertex coordinate plus /// evenly spaced lines per axis. A cell is free only when no edge passes through its /// interior and its centre is inside the region, so results are exact for regions whose /// edges are all horizontal or vertical. Slanted and curved edges are followed as a /// staircase: results stay inside, but can fall short of the true maximum by up to about /// one cell on each side. Rotate the region to search other rectangle angles. /// /// Closed, non-crossing paths, as returned by a Clipper Boolean or offset. /// A point is inside when an odd number of paths enclose it, so holes are subtracted. /// Rectangles narrower than this in either axis are dropped. /// Even subdivisions of the region's bounds per axis, which bound the /// staircase loss along slanted edges. /// Rectangles not contained in another result, largest area first. public static List InRegion(PathsD region, double minDimension = 0, int divisions = 64) { ArgumentNullException.ThrowIfNull(region); ArgumentOutOfRangeException.ThrowIfLessThan(divisions, 1); var paths = region.Where(path => path.Count >= 3).ToList(); if (paths.Count == 0) return new List(); if (paths.Any(path => path.Any(point => !double.IsFinite(point.x) || !double.IsFinite(point.y)))) throw new ArgumentException("Region coordinates must be finite.", nameof(region)); var bounds = Clipper.GetBounds(new PathsD(paths)); var xs = GridLines(paths.SelectMany(path => path).Select(point => point.x), bounds.left, bounds.right, divisions); var ys = GridLines(paths.SelectMany(path => path).Select(point => point.y), bounds.top, bounds.bottom, divisions); if (xs.Count < 2 || ys.Count < 2) return new List(); var rows = ys.Count - 1; var cols = xs.Count - 1; var crossed = new bool[rows, cols]; foreach (var path in paths) { var previous = path[^1]; foreach (var current in path) { MarkCrossedCells(previous, current, xs, ys, crossed); previous = current; } } var empty = new bool[rows, cols]; var crossings = new List(); for (var r = 0; r < rows; r++) { // Even-odd scan along the row's centre line. No vertex lies on it, and an edge that // meets it strictly inside a cell has already marked that cell crossed, so each // uncrossed cell is on the same side as its centre. var y = (ys[r] + ys[r + 1]) / 2; crossings.Clear(); foreach (var path in paths) { var previous = path[^1]; foreach (var current in path) { if ((previous.y > y) != (current.y > y)) crossings.Add(previous.x + (y - previous.y) * (current.x - previous.x) / (current.y - previous.y)); previous = current; } } crossings.Sort(); var passed = 0; for (var c = 0; c < cols; c++) { var x = (xs[c] + xs[c + 1]) / 2; while (passed < crossings.Count && crossings[passed] < x) passed++; empty[r, c] = !crossed[r, c] && passed % 2 == 1; } } return FromGrid(xs, ys, empty, minDimension); } /// /// 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 GridLines(IEnumerable vertices, double min, double max, int divisions) { var lines = new SortedSet(vertices); var exact = lines.ToList(); var step = (max - min) / divisions; for (var i = 1; i < divisions; i++) { // Skip even lines that would only cut a sliver off a vertex line. var line = min + i * step; var index = exact.BinarySearch(line); if (index >= 0) continue; index = ~index; var near = (index > 0 && line - exact[index - 1] < Math.Tolerance.Epsilon) || (index < exact.Count && exact[index] - line < Math.Tolerance.Epsilon); if (!near) lines.Add(line); } return lines.ToList(); } /// Marks every cell whose open interior a slanted edge passes through. private static void MarkCrossedCells(PointD a, PointD b, List xs, List ys, bool[,] crossed) { // Edges along a grid line touch cells without entering them; vertex coordinates // are grid lines, so every horizontal or vertical edge lies on one. if (a.x == b.x || a.y == b.y) return; var c0 = xs.BinarySearch(System.Math.Min(a.x, b.x)); var c1 = xs.BinarySearch(System.Math.Max(a.x, b.x)); var r0 = ys.BinarySearch(System.Math.Min(a.y, b.y)); var r1 = ys.BinarySearch(System.Math.Max(a.y, b.y)); for (var r = r0; r < r1; r++) { for (var c = c0; c < c1; c++) { if (!crossed[r, c] && EntersInterior(a, b, xs[c], ys[r], xs[c + 1], ys[r + 1])) crossed[r, c] = true; } } } /// /// Clips the segment to the closed cell (Liang-Barsky). A segment that enters the open /// interior has the midpoint of its clipped piece strictly inside; one that only touches /// a side or corner does not. /// private static bool EntersInterior(PointD a, PointD b, double left, double bottom, double right, double top) { var dx = b.x - a.x; var dy = b.y - a.y; var t0 = 0.0; var t1 = 1.0; if ( !Clip(-dx, a.x - left, ref t0, ref t1) || !Clip(dx, right - a.x, ref t0, ref t1) || !Clip(-dy, a.y - bottom, ref t0, ref t1) || !Clip(dy, top - a.y, ref t0, ref t1) ) return false; var t = (t0 + t1) / 2; var x = a.x + t * dx; var y = a.y + t * dy; return x > left && x < right && y > bottom && y < top; } private static bool Clip(double p, double q, ref double t0, ref double t1) { if (p == 0) return q >= 0; var ratio = q / p; if (p < 0) { if (ratio > t1) return false; if (ratio > t0) t0 = ratio; } else { if (ratio < t0) return false; if (ratio < t1) t1 = ratio; } return true; } 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; } }