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.
This commit is contained in:
aj
2026-10-01 07:29:05 -04:00
parent 694d4b28cc
commit a26e663f01
2 changed files with 335 additions and 0 deletions
+176
View File
@@ -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;
/// </summary>
public static class MaximalRectangles
{
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// The grid has a line through every vertex coordinate plus <paramref name="divisions"/>
/// 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.
/// </remarks>
/// <param name="region">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.</param>
/// <param name="minDimension">Rectangles narrower than this in either axis are dropped.</param>
/// <param name="divisions">Even subdivisions of the region's bounds per axis, which bound the
/// staircase loss along slanted edges.</param>
/// <returns>Rectangles not contained in another result, largest area first.</returns>
public static List<Box> 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<Box>();
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<Box>();
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<double>();
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);
}
/// <summary>
/// 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<double> GridLines(IEnumerable<double> vertices, double min, double max, int divisions)
{
var lines = new SortedSet<double>(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();
}
/// <summary>Marks every cell whose open interior a slanted edge passes through.</summary>
private static void MarkCrossedCells(PointD a, PointD b, List<double> xs, List<double> 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;
}
}
}
/// <summary>
/// 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.
/// </summary>
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<Box> MergeCells(IReadOnlyList<double> xs, IReadOnlyList<double> ys, bool[,] empty)
{
var rows = empty.GetLength(0);