feat(engine): add RotationPolicy.EnumerateAngles and RotationCandidates

All three plugin engines turned a RotationPolicy into trial angles by hand
(fixed angle, stepped sweep, or right angles plus the minimum-bounding-
rectangle angle for Automatic), each with its own normalization, dedup and
sweep caps. EnumerateAngles gives one deterministic, Allows-checked list;
RotationCandidates.ForShape adds the MBR-aligning angles via the existing
Polygon.FindBestRotation, and DistinctOutlines drops angles where the part
looks identical. A cap of one returns the sweep start rather than throwing,
since engines request a single sample for small orientation budgets.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
aj
2026-09-25 08:33:52 -04:00
co-authored by Codex Claude Opus 5.5
parent dd1a958f5c
commit ec5f57171f
4 changed files with 506 additions and 0 deletions
@@ -0,0 +1,166 @@
using OpenNest.Engine.Jobs;
using OpenNest.Geometry;
namespace OpenNest.Engine.Tests.Jobs;
public class RotationCandidatesTests
{
[Fact]
public void RectangleOnlyNeedsRightAngles()
{
Assert.Equal(RotationPolicy.Automatic.EnumerateAngles(),
RotationCandidates.ForShape(RotationPolicy.Automatic, Rectangle()));
}
[Fact]
public void RotatedRectangleAddsExistingCalipersAnglesWithoutMutatingShape()
{
var shape = Rectangle();
shape.Rotate(0.3);
var before = shape.ToPolygonWithTolerance(0.1).Vertices.ToArray();
var angles = RotationCandidates.ForShape(RotationPolicy.Automatic, shape);
Assert.Equal(8, angles.Count);
var expected = -shape.ToPolygonWithTolerance(0.1).FindBestRotation().Angle;
for (var turn = 0; turn < 4; turn++)
{
var normalized = (expected + turn * System.Math.PI / 2 + 2 * System.Math.PI)
% (2 * System.Math.PI);
Assert.Equal(normalized, angles[4 + turn], 10);
}
Assert.All(angles, angle => Assert.True(RotationPolicy.Automatic.Allows(angle)));
Assert.Equal(before, shape.ToPolygonWithTolerance(0.1).Vertices);
Assert.Equal(angles, RotationCandidates.ForShape(RotationPolicy.Automatic, shape));
}
[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(3)]
[InlineData(4)]
[InlineData(5)]
public void LimitPreservesRightAnglesFirst(int limit)
{
var shape = Rectangle();
shape.Rotate(0.3);
var all = RotationCandidates.ForShape(RotationPolicy.Automatic, shape);
Assert.Equal(all.Take(limit), RotationCandidates.ForShape(RotationPolicy.Automatic, shape, limit));
Assert.Equal(RotationPolicy.Automatic.EnumerateAngles().Take(System.Math.Min(limit, 4)),
all.Take(System.Math.Min(limit, 4)));
}
[Fact]
public void RestrictedPoliciesDoNotAddShapeAngles()
{
var shape = Rectangle();
shape.Rotate(0.3);
foreach (var policy in new[] { RotationPolicy.Fixed(0.1, true),
RotationPolicy.BoundedSweep(5, 7, 0.1, true) })
{
var angles = RotationCandidates.ForShape(policy, shape);
Assert.Equal(policy.EnumerateAngles(), angles);
Assert.All(angles, angle => Assert.True(policy.Allows(angle)));
}
}
[Theory]
[InlineData("empty")]
[InlineData("open")]
[InlineData("zero-area")]
[InlineData("nan")]
[InlineData("infinity")]
[InlineData("invalid-circle")]
public void InvalidPerimeterFallsBackToPolicyAngles(string kind)
{
var shape = new Shape();
if (kind == "open")
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(1, 1)));
if (kind == "zero-area")
{
shape.Entities.Add(new Line(new Vector(0, 0), new Vector(1, 1)));
shape.Entities.Add(new Line(new Vector(1, 1), new Vector(0, 0)));
}
if (kind is "nan" or "infinity")
{
shape = Rectangle();
((Line)shape.Entities[0]).StartPoint = new Vector(
kind == "nan" ? double.NaN : double.PositiveInfinity, 0);
}
if (kind == "invalid-circle")
shape.Entities.Add(new Circle(0, 0, -1));
Assert.Equal(RotationPolicy.Automatic.EnumerateAngles(),
RotationCandidates.ForShape(RotationPolicy.Automatic, shape));
}
[Fact]
public void DiscCollapsesToFirstOrientationIncludingArbitraryAngles()
{
var shape = new Shape();
shape.Entities.Add(new Circle(3, 7, 2));
Assert.Equal(new[] { 0.3 }, RotationCandidates.DistinctOutlines(shape,
new[] { 0.3, 0, 0.7, System.Math.PI / 2, System.Math.PI }));
}
[Fact]
public void RectangleHasTwoOutlinesAndPreservesFirstOccurrence()
{
var shape = Rectangle();
shape.Offset(12, -7);
var before = shape.ToPolygon().Vertices.ToArray();
Assert.Equal(new[] { 0.0, System.Math.PI / 2 }, RotationCandidates.DistinctOutlines(
shape, RotationPolicy.Automatic.EnumerateAngles()));
Assert.Equal(new[] { System.Math.PI, 3 * System.Math.PI / 2 },
RotationCandidates.DistinctOutlines(shape,
new[] { System.Math.PI, -System.Math.PI / 2, 0, System.Math.PI / 2 }));
Assert.Equal(before, shape.ToPolygon().Vertices);
}
[Fact]
public void AsymmetricOutlineDoesNotCollapseEvenWithSquareBounds()
{
var shape = Polygon(new Vector(0, 0), new Vector(4, 0), new Vector(1, 4));
Assert.Equal(4, RotationCandidates.DistinctOutlines(shape,
RotationPolicy.Automatic.EnumerateAngles()).Count);
}
[Fact]
public void OutlineToleranceControlsNearSymmetry()
{
var shape = Polygon(new Vector(0, 0), new Vector(4, 0),
new Vector(4, 4.000001), new Vector(0, 4.000001));
Assert.Single(RotationCandidates.DistinctOutlines(shape,
RotationPolicy.Automatic.EnumerateAngles(), 1e-5));
Assert.Equal(2, RotationCandidates.DistinctOutlines(shape,
RotationPolicy.Automatic.EnumerateAngles(), 1e-8).Count);
}
[Fact]
public void NonFiniteAndDuplicateAnglesAreOmitted()
{
Assert.Equal(new[] { 0.0 }, RotationCandidates.DistinctOutlines(Rectangle(),
new[] { double.NaN, 0, 1e-8, 2 * System.Math.PI, double.PositiveInfinity }));
}
[Fact]
public void InvalidArgumentsThrow()
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
RotationCandidates.ForShape(RotationPolicy.Automatic, Rectangle(), -1));
foreach (var tolerance in new[] { 0, -1, double.NaN, double.PositiveInfinity })
Assert.Throws<ArgumentOutOfRangeException>(() =>
RotationCandidates.DistinctOutlines(Rectangle(), new[] { 0.0 }, tolerance));
Assert.Throws<ArgumentNullException>(() => RotationCandidates.ForShape(null!, Rectangle()));
Assert.Throws<ArgumentNullException>(() => RotationCandidates.ForShape(RotationPolicy.Automatic, null!));
Assert.Throws<ArgumentException>(() => RotationCandidates.DistinctOutlines(new Shape(), new[] { 0.0 }));
}
private static Shape Rectangle() => Polygon(new Vector(0, 0), new Vector(4, 0),
new Vector(4, 2), new Vector(0, 2));
private static Shape Polygon(params Vector[] points)
{
var shape = new Shape();
for (var index = 0; index < points.Length; index++)
shape.Entities.Add(new Line(points[index], points[(index + 1) % points.Length]));
return shape;
}
}
@@ -0,0 +1,116 @@
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.Tests.Jobs;
public class RotationPolicyTests
{
[Fact]
public void AutomaticReturnsRightAnglesInOrder()
{
Assert.Equal(new[] { 0, System.Math.PI / 2, System.Math.PI, 3 * System.Math.PI / 2 },
RotationPolicy.Automatic.EnumerateAngles());
}
[Fact]
public void FixedIncludesNormalizedHalfTurnEquivalent()
{
var policy = RotationPolicy.Fixed(-System.Math.PI / 2, true);
Assert.Equal(new[] { 3 * System.Math.PI / 2, System.Math.PI / 2 }, policy.EnumerateAngles(1));
AssertLegal(policy);
}
[Fact]
public void OversizedSweepEvenlySamplesGridIncludingEndpoints()
{
var policy = RotationPolicy.BoundedSweep(0, 1, 0.01);
Assert.Equal(new[] { 0, 0.25, 0.5, 0.75, 1 }, policy.EnumerateAngles(5));
AssertLegal(policy, 5);
}
[Fact]
public void SubsamplingRoundsToLegalGridPoints()
{
var policy = RotationPolicy.BoundedSweep(0, 1, 0.1);
var angles = policy.EnumerateAngles(4);
Assert.Equal(4, angles.Count);
Assert.Equal(0.3, angles[1], 10);
Assert.Equal(0.7, angles[2], 10);
Assert.Equal(1, angles[3]);
AssertLegal(policy, 4);
}
[Fact]
public void OffGridEndUsesLastLegalGridPoint()
{
var policy = RotationPolicy.BoundedSweep(0, 1, 0.3);
Assert.Equal(0.9, policy.EnumerateAngles(2)[1], 10);
AssertLegal(policy, 2);
}
[Fact]
public void SweepEquivalentsFollowEachBaseAngleAndAreDeduplicated()
{
var policy = RotationPolicy.BoundedSweep(0, System.Math.PI, System.Math.PI / 2, true);
Assert.Equal(new[] { 0, System.Math.PI, System.Math.PI / 2, 3 * System.Math.PI / 2 },
policy.EnumerateAngles());
AssertLegal(policy);
}
[Fact]
public void SweepCrossingFullTurnNormalizesWithoutSorting()
{
var policy = RotationPolicy.BoundedSweep(3 * System.Math.PI / 2,
5 * System.Math.PI / 2, System.Math.PI / 2, true);
Assert.Equal(new[] { 3 * System.Math.PI / 2, System.Math.PI / 2, 0, System.Math.PI },
policy.EnumerateAngles());
AssertLegal(policy);
}
[Fact]
public void FullTurnAndNearDuplicateAnglesCollapse()
{
Assert.Equal(4, RotationPolicy.BoundedSweep(0, 2 * System.Math.PI,
System.Math.PI / 2).EnumerateAngles().Count);
Assert.Single(RotationPolicy.BoundedSweep(-1e-8, 1e-8, 1e-8).EnumerateAngles());
}
[Fact]
public void OneSampleReturnsTheSweepStart()
{
Assert.Single(RotationPolicy.BoundedSweep(0.2, 0.3, 1).EnumerateAngles(1));
Assert.Equal(new[] { 0.0 }, RotationPolicy.BoundedSweep(0, 1, 0.5).EnumerateAngles(1));
Assert.Equal(new[] { 0.0, System.Math.PI },
RotationPolicy.BoundedSweep(0, 1, 0.5, true).EnumerateAngles(1));
Assert.Equal(4, RotationPolicy.Automatic.EnumerateAngles(1).Count);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void InvalidSampleCapThrows(int maxSamples)
{
Assert.Throws<ArgumentOutOfRangeException>(() =>
RotationPolicy.Fixed(0).EnumerateAngles(maxSamples));
}
[Fact]
public void DefaultSweepCapIs720BaseSamples()
{
var policy = RotationPolicy.BoundedSweep(0, 1, 0.0001, true);
var angles = policy.EnumerateAngles();
Assert.Equal(1440, angles.Count);
Assert.Equal(1, angles[^2]);
AssertLegal(policy);
}
private static void AssertLegal(RotationPolicy policy, int maxSamples = 720)
{
var angles = policy.EnumerateAngles(maxSamples);
Assert.Equal(angles, policy.EnumerateAngles(maxSamples));
Assert.All(angles, angle =>
{
Assert.True(angle >= 0 && angle < 2 * System.Math.PI);
Assert.True(policy.Allows(angle));
});
}
}