fix(geometry): check every hull edge for the minimum bounding rectangle

MinimumBoundingRectangle(Polygon) evaluated only the first (n + 1) / 2
edges, assuming opposite edges give the same rectangle. That holds only
for centrally symmetric hulls: for the triangle (0,0), (10,0), (12,1) it
returned area 12 instead of 10, depending on vertex order.

Every edge is now a candidate. A second-half edge replaces the best only
when smaller beyond rounding, so symmetric hulls (rectangles, best-fit
pairs, whose MBR angle PairEvaluator uses directly) keep their previous
orientation instead of flipping 180 degrees on floating-point noise.

On the Desktop problem nests, 8 of 160 drawing entries now get a smaller
MBR (0.004% to 3.2%) and a turned canonical frame; none changed without a
real area gain. The problem-nest corpus is unchanged against f51e5aef.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-20 21:41:27 -04:00
1 parent 0922e41401
commit 059a873d32
2 files changed
+66 -6

No files matched your search

+9 -6
View File
@@ -47,14 +47,16 @@ namespace OpenNest.Geometry
return new BoundingRectangleResult(angle, length, 0);
}
// Only need half the edges — opposite edges produce the same
// bounding rectangle (width/height swapped), so the minimum
// area repeats every 180°.
var edgeCount = (n + 1) / 2;
// Every edge is a candidate: opposite edges only give the same rectangle
// when the hull is centrally symmetric. For those hulls (rectangles,
// best-fit pairs) the second half repeats the first turned 180°, so a
// second-half edge wins only when smaller beyond rounding; that keeps
// their orientation from flipping on floating-point noise.
var half = (n + 1) / 2;
BoundingRectangleResult best = null;
for (int i = 0; i < edgeCount; i++)
for (int i = 0; i < n; i++)
{
int next = (i + 1) % n;
var edgeX = vertices[next].X - vertices[i].X;
@@ -94,8 +96,9 @@ namespace OpenNest.Geometry
var width = maxU - minU;
var height = maxV - minV;
var area = width * height;
var margin = i < half || best == null ? 0 : Tolerance.Epsilon * best.Area;
if (best == null || area < best.Area)
if (best == null || area < best.Area - margin)
{
var edgeAngle = System.Math.Atan2(edgeY, edgeX);
best = new BoundingRectangleResult(edgeAngle, width, height);
@@ -0,0 +1,57 @@
using OpenNest.Geometry;
namespace OpenNest.Tests.Geometry;
public class RotatingCalipersTests
{
private static readonly Vector[] Triangle = { new(0, 0), new(10, 0), new(12, 1) };
private static Polygon HullStartingAt(Vector[] vertices, int start)
{
var hull = new Polygon();
for (var i = 0; i < vertices.Length; i++)
hull.Vertices.Add(vertices[(start + i) % vertices.Length]);
return hull;
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(2)]
public void AsymmetricHull_FindsTheBestEdgeInAnyVertexOrder(int start)
{
// The edge (12,1)-(0,0) gives area 10; the others give 12 and 50. Starting at
// (0,0), that edge is last, in the half the opposite-edge shortcut skipped.
var result = RotatingCalipers.MinimumBoundingRectangle(HullStartingAt(Triangle, start));
Assert.Equal(10, result.Area, 9);
Assert.Equal(System.Math.Sqrt(145), result.Width, 9);
Assert.Equal(System.Math.Atan2(-1, -12), result.Angle, 9);
}
[Fact]
public void PointList_FindsTheBestEdgeOfItsHull()
{
var result = RotatingCalipers.MinimumBoundingRectangle(Triangle);
Assert.Equal(10, result.Area, 9);
}
[Fact]
public void SymmetricHull_KeepsAFirstHalfEdgeOrientation()
{
// A rotated rectangle's opposite edges tie up to rounding; the result must stay
// on the first two edges rather than flip 180° onto the last two.
var angle = 0.4;
var corners = new[] { new Vector(0, 0), new Vector(30, 0), new Vector(30, 7), new Vector(0, 7) };
var hull = new Polygon();
foreach (var corner in corners)
hull.Vertices.Add(corner.Rotate(angle));
var result = RotatingCalipers.MinimumBoundingRectangle(hull);
Assert.Equal(210, result.Area, 6);
var firstHalfAngles = new[] { angle, angle + System.Math.PI / 2 }.Select(a => System.Math.Round(a, 9));
Assert.Contains(System.Math.Round(result.Angle, 9), firstHalfAngles);
}
}