From 059a873d322cc084084f5e99c992a4836e97c31e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 20 Oct 2026 21:02:47 -0400 Subject: [PATCH] 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 --- OpenNest.Core/Geometry/RotatingCalipers.cs | 15 +++-- .../Geometry/RotatingCalipersTests.cs | 57 +++++++++++++++++++ 2 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 OpenNest.Tests/Geometry/RotatingCalipersTests.cs diff --git a/OpenNest.Core/Geometry/RotatingCalipers.cs b/OpenNest.Core/Geometry/RotatingCalipers.cs index 8e2989df..550748ef 100644 --- a/OpenNest.Core/Geometry/RotatingCalipers.cs +++ b/OpenNest.Core/Geometry/RotatingCalipers.cs @@ -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); diff --git a/OpenNest.Tests/Geometry/RotatingCalipersTests.cs b/OpenNest.Tests/Geometry/RotatingCalipersTests.cs new file mode 100644 index 00000000..0600142f --- /dev/null +++ b/OpenNest.Tests/Geometry/RotatingCalipersTests.cs @@ -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); + } +}