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