From a26e663f0179187b40449aa921720ffb362f8a54 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 1 Oct 2026 07:29:05 -0400 Subject: [PATCH] feat(geometry): find maximal rectangles inside any region MaximalRectangles.InRegion finds the largest axis-aligned rectangles that fit wholly inside a Clipper region, such as a cutout shrunk by the part spacing. It grids the region on its vertex coordinates plus even divisions, keeps a cell only when no edge enters it and an even-odd row scan puts it inside, then runs the shared histogram search. Exact for regions with only horizontal and vertical edges; slanted and curved edges are followed as a staircase that never crosses the boundary, short of the true maximum by up to about one cell per side. Tests cover a rectangle, an L shape, a frame with a hole, a round hole (inscribed square), a diamond and a star; disabling the edge-crossing check fails the three slanted-edge tests. --- OpenNest.Core/Geometry/MaximalRectangles.cs | 176 ++++++++++++++++++ .../Geometry/MaximalRectanglesTests.cs | 159 ++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 OpenNest.Tests/Geometry/MaximalRectanglesTests.cs diff --git a/OpenNest.Core/Geometry/MaximalRectangles.cs b/OpenNest.Core/Geometry/MaximalRectangles.cs index 7200056..b4204f4 100644 --- a/OpenNest.Core/Geometry/MaximalRectangles.cs +++ b/OpenNest.Core/Geometry/MaximalRectangles.cs @@ -1,4 +1,7 @@ +using System; using System.Collections.Generic; +using System.Linq; +using Clipper2Lib; namespace OpenNest.Geometry; @@ -8,6 +11,89 @@ namespace OpenNest.Geometry; /// 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 @@ -30,6 +116,96 @@ public static class MaximalRectangles 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); diff --git a/OpenNest.Tests/Geometry/MaximalRectanglesTests.cs b/OpenNest.Tests/Geometry/MaximalRectanglesTests.cs new file mode 100644 index 0000000..e290413 --- /dev/null +++ b/OpenNest.Tests/Geometry/MaximalRectanglesTests.cs @@ -0,0 +1,159 @@ +using Clipper2Lib; +using OpenNest.Geometry; + +namespace OpenNest.Tests.Geometry; + +public class MaximalRectanglesTests +{ + [Fact] + public void InRegion_Rectangle_ReturnsItself() + { + var result = MaximalRectangles.InRegion(Region(Rect(2, 3, 12, 8))); + + var box = Assert.Single(result); + AssertBox(box, 2, 3, 10, 5); + } + + [Fact] + public void InRegion_LShape_ReturnsBothArmsExactly() + { + var lShape = Path(0, 0, 10, 0, 10, 4, 4, 4, 4, 10, 0, 10); + + var result = MaximalRectangles.InRegion(Region(lShape)); + + Assert.Equal(2, result.Count); + Assert.Contains(result, box => Matches(box, 0, 0, 10, 4)); + Assert.Contains(result, box => Matches(box, 0, 0, 4, 10)); + } + + [Fact] + public void InRegion_FrameWithHole_ReturnsTheFourSides() + { + var hole = Rect(5, 5, 15, 15); + hole.Reverse(); + + var result = MaximalRectangles.InRegion(Region(Rect(0, 0, 20, 20), hole)); + + Assert.Equal(4, result.Count); + Assert.Contains(result, box => Matches(box, 0, 0, 20, 5)); + Assert.Contains(result, box => Matches(box, 0, 15, 20, 5)); + Assert.Contains(result, box => Matches(box, 0, 0, 5, 20)); + Assert.Contains(result, box => Matches(box, 15, 0, 5, 20)); + } + + [Fact] + public void InRegion_RoundHole_FindsNearlyTheInscribedSquare() + { + // The largest rectangle in a circle of radius 5 is a square of area 2r^2 = 50. + var circle = Circle(10, 10, 5, 256); + + var result = MaximalRectangles.InRegion(Region(circle)); + + Assert.NotEmpty(result); + Assert.InRange(result[0].Area(), 47.5, 50 + 1e-9); + AssertAllInside(result, Region(circle)); + } + + [Fact] + public void InRegion_Diamond_NeedsDivisionsToFollowSlantedEdges() + { + // Vertex lines alone cut the diamond into four cells, each crossed by an edge. Even + // lines let the staircase reach the inscribed square, whose corners touch the edges. + var diamond = Region(Path(5, 0, 10, 5, 5, 10, 0, 5)); + + Assert.Empty(MaximalRectangles.InRegion(diamond, divisions: 1)); + var result = MaximalRectangles.InRegion(diamond, divisions: 16); + AssertBox(result[0], 2.5, 2.5, 5, 5); + } + + [Fact] + public void InRegion_SlantedEdges_NeverCrossTheBoundary() + { + // Rotated square and a star: every edge is slanted, so every result is a staircase fit. + var diamond = Path(5, 0, 10, 5, 5, 10, 0, 5); + var star = new PathD(); + for (var i = 0; i < 10; i++) + { + var angle = System.Math.PI * i / 5 + 0.1; + var radius = i % 2 == 0 ? 10 : 4; + star.Add(new PointD(radius * System.Math.Cos(angle), radius * System.Math.Sin(angle))); + } + + foreach (var region in new[] { Region(diamond), Region(star) }) + { + var result = MaximalRectangles.InRegion(region, divisions: 16); + + Assert.NotEmpty(result); + AssertAllInside(result, region); + } + } + + [Fact] + public void InRegion_MinDimension_DropsNarrowRectangles() + { + var lShape = Path(0, 0, 10, 0, 10, 4, 4, 4, 4, 10, 0, 10); + + var result = MaximalRectangles.InRegion(Region(lShape), minDimension: 5); + + Assert.Empty(result); + } + + [Fact] + public void InRegion_EmptyRegion_ReturnsNothing() + { + Assert.Empty(MaximalRectangles.InRegion(new PathsD())); + } + + private static PathsD Region(params PathD[] paths) => new(paths); + + private static PathD Rect(double left, double bottom, double right, double top) => + Path(left, bottom, right, bottom, right, top, left, top); + + private static PathD Path(params double[] coordinates) + { + var path = new PathD(); + for (var i = 0; i < coordinates.Length; i += 2) + path.Add(new PointD(coordinates[i], coordinates[i + 1])); + return path; + } + + private static PathD Circle(double x, double y, double radius, int vertices) + { + var path = new PathD(); + for (var i = 0; i < vertices; i++) + { + var angle = 2 * System.Math.PI * i / vertices; + path.Add(new PointD(x + radius * System.Math.Cos(angle), y + radius * System.Math.Sin(angle))); + } + return path; + } + + private static bool Matches(Box box, double x, double y, double length, double width) => + System.Math.Abs(box.X - x) < 1e-9 + && System.Math.Abs(box.Y - y) < 1e-9 + && System.Math.Abs(box.Length - length) < 1e-9 + && System.Math.Abs(box.Width - width) < 1e-9; + + private static void AssertBox(Box box, double x, double y, double length, double width) => + Assert.True( + Matches(box, x, y, length, width), + $"Expected ({x}, {y}) {length} x {width}, got ({box.X}, {box.Y}) {box.Length} x {box.Width}." + ); + + private static void AssertAllInside(IEnumerable boxes, PathsD region) + { + foreach (var box in boxes) + { + var outside = Clipper.Difference( + Region(Rect(box.Left, box.Bottom, box.Right, box.Top)), + region, + FillRule.EvenOdd, + 8 + ); + Assert.True( + Clipper.Area(outside) < 1e-9, + $"({box.X}, {box.Y}) {box.Length} x {box.Width} leaves the region by {Clipper.Area(outside)}." + ); + } + } +}