Files
OpenNest/OpenNest.Tests/Geometry/MaximalRectanglesTests.cs
T
aj a26e663f01 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.
2026-10-01 07:29:05 -04:00

160 lines
5.3 KiB
C#

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