Files
OpenNest/OpenNest.Core/Geometry/RotatingCalipers.cs
T
ajandClaude Opus 5.5 059a873d32 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>
2026-10-20 21:41:27 -04:00

216 lines
7.4 KiB
C#

using System.Collections.Generic;
using OpenNest.Math;
namespace OpenNest.Geometry
{
public class BoundingRectangleResult
{
public BoundingRectangleResult(double angle, double width, double height)
{
Angle = angle;
Width = width;
Height = height;
Area = width * height;
}
public double Angle { get; }
public double Width { get; }
public double Height { get; }
public double Area { get; }
}
public static class RotatingCalipers
{
public static BoundingRectangleResult MinimumBoundingRectangle(IList<Vector> points)
{
var hull = ConvexHull.Compute(points);
return MinimumBoundingRectangle(hull);
}
public static BoundingRectangleResult MinimumBoundingRectangle(Polygon hull)
{
var vertices = hull.Vertices;
int n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count;
if (n == 0)
return new BoundingRectangleResult(0, 0, 0);
if (n == 1)
return new BoundingRectangleResult(0, 0, 0);
if (n == 2)
{
var dx = vertices[1].X - vertices[0].X;
var dy = vertices[1].Y - vertices[0].Y;
var angle = System.Math.Atan2(dy, dx);
var length = System.Math.Sqrt(dx * dx + dy * dy);
return new BoundingRectangleResult(angle, length, 0);
}
// 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 < n; i++)
{
int next = (i + 1) % n;
var edgeX = vertices[next].X - vertices[i].X;
var edgeY = vertices[next].Y - vertices[i].Y;
var edgeLen = System.Math.Sqrt(edgeX * edgeX + edgeY * edgeY);
if (edgeLen.IsEqualTo(0))
continue;
// Unit vectors along and perpendicular to this edge
var ux = edgeX / edgeLen;
var uy = edgeY / edgeLen;
var vx = -uy;
var vy = ux;
// Project all hull vertices onto edge direction (u) and perpendicular (v)
double minU = double.MaxValue,
maxU = double.MinValue;
double minV = double.MaxValue,
maxV = double.MinValue;
for (int j = 0; j < n; j++)
{
var projU = vertices[j].X * ux + vertices[j].Y * uy;
var projV = vertices[j].X * vx + vertices[j].Y * vy;
if (projU < minU)
minU = projU;
if (projU > maxU)
maxU = projU;
if (projV < minV)
minV = projV;
if (projV > maxV)
maxV = projV;
}
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 - margin)
{
var edgeAngle = System.Math.Atan2(edgeY, edgeX);
best = new BoundingRectangleResult(edgeAngle, width, height);
}
}
return best ?? new BoundingRectangleResult(0, 0, 0);
}
public static BoundingRectangleResult MinimumBoundingRectangle(
Polygon hull,
double startAngle,
double endAngle
)
{
var vertices = hull.Vertices;
int n = hull.IsClosed() ? vertices.Count - 1 : vertices.Count;
if (n < 3)
return MinimumBoundingRectangle(hull);
startAngle = Angle.NormalizeRad(startAngle);
if (!endAngle.IsEqualTo(Angle.TwoPI))
endAngle = Angle.NormalizeRad(endAngle);
var candidates = new List<BoundingRectangleResult>();
// Evaluate at each hull edge angle
for (int i = 0; i < n; i++)
{
int next = (i + 1) % n;
var edgeX = vertices[next].X - vertices[i].X;
var edgeY = vertices[next].Y - vertices[i].Y;
var edgeLen = System.Math.Sqrt(edgeX * edgeX + edgeY * edgeY);
if (edgeLen.IsEqualTo(0))
continue;
var edgeAngle = Angle.NormalizeRad(System.Math.Atan2(edgeY, edgeX));
// Each edge produces 4 candidate angles (0, 90, 180, 270 relative to edge)
for (int k = 0; k < 4; k++)
{
var candidateAngle = Angle.NormalizeRad(edgeAngle + k * Angle.HalfPI);
if (IsAngleInRange(candidateAngle, startAngle, endAngle))
candidates.Add(EvaluateAtAngle(vertices, n, candidateAngle));
}
}
// Also evaluate at range boundaries
candidates.Add(EvaluateAtAngle(vertices, n, startAngle));
if (!endAngle.IsEqualTo(Angle.TwoPI))
candidates.Add(EvaluateAtAngle(vertices, n, endAngle));
BoundingRectangleResult best = null;
foreach (var candidate in candidates)
{
if (best == null || candidate.Area < best.Area)
best = candidate;
}
return best ?? new BoundingRectangleResult(startAngle, 0, 0);
}
private static BoundingRectangleResult EvaluateAtAngle(
IList<Vector> vertices,
int n,
double angle
)
{
var cos = System.Math.Cos(angle);
var sin = System.Math.Sin(angle);
double minU = double.MaxValue,
maxU = double.MinValue;
double minV = double.MaxValue,
maxV = double.MinValue;
for (int j = 0; j < n; j++)
{
var projU = vertices[j].X * cos + vertices[j].Y * sin;
var projV = -vertices[j].X * sin + vertices[j].Y * cos;
if (projU < minU)
minU = projU;
if (projU > maxU)
maxU = projU;
if (projV < minV)
minV = projV;
if (projV > maxV)
maxV = projV;
}
var width = maxU - minU;
var height = maxV - minV;
return new BoundingRectangleResult(angle, width, height);
}
private static bool IsAngleInRange(double angle, double start, double end)
{
if (end.IsEqualTo(Angle.TwoPI) && start.IsEqualTo(0))
return true;
if (start <= end)
return angle >= start - Tolerance.Epsilon && angle <= end + Tolerance.Epsilon;
// Range wraps around 0 (e.g., 350 to 10 degrees)
return angle >= start - Tolerance.Epsilon || angle <= end + Tolerance.Epsilon;
}
}
}