mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-02 13:38:48 -04:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user