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:
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
|
||||
/// <summary>Deterministic rotation candidates and optional perimeter symmetry reduction.</summary>
|
||||
public static class RotationCandidates
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns policy radians first, followed, for Automatic only, by the rotation aligning
|
||||
/// the minimum bounding rectangle and its three right-angle turns. Uses the same 0.1
|
||||
/// chord tolerance and polygon rotating-calipers implementation as Fill's rotation analysis.
|
||||
/// Results satisfy <see cref="RotationPolicy.Allows"/>, are normalized to [0, 2π), and
|
||||
/// deduplicated with a circular tolerance of 1e-7 radians, preserving first occurrence.
|
||||
/// Empty, open, non-finite or degenerate perimeters fall back to policy angles.
|
||||
/// The default policy sweep cap is 720 base samples; limit truncates the combined list,
|
||||
/// keeping policy angles (the four right angles for Automatic) first. Zero returns empty.
|
||||
/// The perimeter is not modified.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentNullException">An argument is null.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">The limit is negative.</exception>
|
||||
public static IReadOnlyList<double> ForShape(
|
||||
RotationPolicy policy, Shape perimeter, int limit = int.MaxValue)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(policy);
|
||||
ArgumentNullException.ThrowIfNull(perimeter);
|
||||
if (limit < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(limit));
|
||||
var angles = new List<double>(policy.EnumerateAngles());
|
||||
if (policy.Kind == RotationPolicyKind.Automatic && limit > angles.Count)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsUsable(perimeter))
|
||||
{
|
||||
var polygon = perimeter.ToPolygonWithTolerance(0.1);
|
||||
// Polygon.FindBestRotation computes the convex hull and invokes RotatingCalipers.
|
||||
var rectangle = polygon.FindBestRotation();
|
||||
if (double.IsFinite(rectangle.Area) && rectangle.Area > 0)
|
||||
for (var turn = 0; turn < 4; turn++)
|
||||
policy.AddAngle(angles, -rectangle.Angle + turn * (System.Math.PI / 2));
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is ArgumentException
|
||||
or InvalidOperationException or NotSupportedException or ArithmeticException)
|
||||
{
|
||||
// Shape-derived candidates are optional for unreadable geometry.
|
||||
}
|
||||
}
|
||||
return angles.Take(limit).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the first angle for each distinct flattened perimeter, ignoring translation
|
||||
/// by moving each outline to its minimum X/Y corner. Angles are radians, normalized to
|
||||
/// [0, 2π), deduplicated with a circular tolerance of 1e-7 radians and kept in input order.
|
||||
/// Non-finite angles are omitted. No new orientations are introduced; callers requiring
|
||||
/// a policy should supply its legal candidates. The perimeter is cloned before rotation.
|
||||
/// Outlines are flattened with chord tolerance tolerance/4 and match when every vertex
|
||||
/// is within tolerance of the other outline's segments in both directions.
|
||||
/// This compares only the perimeter, not cutouts or marks.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentNullException">An argument is null.</exception>
|
||||
/// <exception cref="ArgumentOutOfRangeException">Tolerance is not finite and positive.</exception>
|
||||
/// <exception cref="ArgumentException">The perimeter is not usable closed geometry.</exception>
|
||||
public static IReadOnlyList<double> DistinctOutlines(
|
||||
Shape perimeter, IEnumerable<double> angles, double tolerance = 1e-5)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(perimeter);
|
||||
ArgumentNullException.ThrowIfNull(angles);
|
||||
if (!double.IsFinite(tolerance) || tolerance <= 0 || tolerance / 4 == 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(tolerance));
|
||||
if (!IsUsable(perimeter))
|
||||
throw new ArgumentException("A usable closed perimeter is required.", nameof(perimeter));
|
||||
|
||||
var candidates = new List<double>();
|
||||
foreach (var angle in angles)
|
||||
RotationPolicy.Automatic.AddAngle(candidates, angle);
|
||||
var result = new List<double>();
|
||||
var outlines = new List<List<Vector>>();
|
||||
foreach (var angle in candidates)
|
||||
{
|
||||
var rotated = (Shape)perimeter.Clone();
|
||||
rotated.Rotate(angle);
|
||||
var points = rotated.ToPolygonWithTolerance(tolerance / 4).Vertices;
|
||||
var corner = new Vector(points.Min(p => p.X), points.Min(p => p.Y));
|
||||
var outline = points.Select(p => p - corner).ToList();
|
||||
if (outlines.Any(previous => Matches(previous, outline, tolerance)))
|
||||
continue;
|
||||
outlines.Add(outline);
|
||||
result.Add(angle);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsUsable(Shape perimeter)
|
||||
{
|
||||
if (perimeter.Entities == null || perimeter.Entities.Count == 0)
|
||||
return false;
|
||||
foreach (var entity in perimeter.Entities)
|
||||
{
|
||||
var finite = entity switch
|
||||
{
|
||||
Line line => IsFinite(line.StartPoint) && IsFinite(line.EndPoint),
|
||||
Arc arc => IsFinite(arc.Center) && double.IsFinite(arc.Radius) && arc.Radius > 0
|
||||
&& double.IsFinite(arc.StartAngle) && double.IsFinite(arc.EndAngle),
|
||||
Circle circle => IsFinite(circle.Center)
|
||||
&& double.IsFinite(circle.Radius) && circle.Radius > 0,
|
||||
_ => false,
|
||||
};
|
||||
if (!finite || !double.IsFinite(entity.Length) || entity.Length <= 0)
|
||||
return false;
|
||||
}
|
||||
return perimeter.IsClosed() && double.IsFinite(perimeter.Area()) && perimeter.Area() > 0;
|
||||
}
|
||||
|
||||
private static bool IsFinite(Vector point) => double.IsFinite(point.X) && double.IsFinite(point.Y);
|
||||
|
||||
private static bool Matches(List<Vector> first, List<Vector> second, double tolerance)
|
||||
{
|
||||
if (System.Math.Abs(first.Max(p => p.X) - second.Max(p => p.X)) > tolerance
|
||||
|| System.Math.Abs(first.Max(p => p.Y) - second.Max(p => p.Y)) > tolerance)
|
||||
return false;
|
||||
if (first.Count == second.Count
|
||||
&& first.Zip(second).All(pair => pair.First.DistanceTo(pair.Second) <= tolerance))
|
||||
return true;
|
||||
return NearSegments(first, second, tolerance) && NearSegments(second, first, tolerance);
|
||||
}
|
||||
|
||||
private static bool NearSegments(List<Vector> points, List<Vector> outline, double tolerance)
|
||||
{
|
||||
foreach (var point in points)
|
||||
{
|
||||
var near = false;
|
||||
for (var index = 0; index < outline.Count; index++)
|
||||
{
|
||||
var start = outline[index];
|
||||
var edge = outline[(index + 1) % outline.Count] - start;
|
||||
var lengthSquared = edge.X * edge.X + edge.Y * edge.Y;
|
||||
var delta = point - start;
|
||||
var fraction = lengthSquared == 0 ? 0
|
||||
: System.Math.Clamp((delta.X * edge.X + delta.Y * edge.Y) / lengthSquared, 0, 1);
|
||||
if (point.DistanceTo(start + edge * fraction) <= tolerance)
|
||||
{
|
||||
near = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!near)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
|
||||
@@ -71,6 +72,72 @@ public sealed class RotationPolicy
|
||||
? Automatic
|
||||
: BoundedSweep(rotationStart, rotationEnd, stepAngle, allow180Equivalent);
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates legal radians in stable order, normalized to [0, 2π) and deduplicated
|
||||
/// with a circular tolerance of 1e-7 radians (zero and a full turn are equivalent).
|
||||
/// Fixed returns the start; Automatic returns 0, π/2, π, 3π/2.
|
||||
/// Sweeps follow the step grid from Start through the last grid point at or before End.
|
||||
/// If necessary, grid indices are evenly subsampled (rounded to the nearest index),
|
||||
/// including both grid endpoints when maxSamples is at least two; a cap of one returns
|
||||
/// Start alone. An off-grid End is not legal and is not included.
|
||||
/// Allowed 180-degree equivalents immediately follow each base angle and do not count
|
||||
/// toward maxSamples. Every returned angle satisfies <see cref="Allows"/>.
|
||||
/// </summary>
|
||||
/// <param name="maxSamples">Positive cap on base sweep samples, before deduplication.</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">The sample cap is not positive.</exception>
|
||||
/// <exception cref="InvalidOperationException">The sweep grid exceeds finite numeric range.</exception>
|
||||
public IReadOnlyList<double> EnumerateAngles(int maxSamples = 720)
|
||||
{
|
||||
if (maxSamples < 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxSamples));
|
||||
|
||||
var angles = new List<double>();
|
||||
if (Kind == RotationPolicyKind.Automatic)
|
||||
{
|
||||
for (var turn = 0; turn < 4; turn++)
|
||||
AddAngle(angles, turn * (System.Math.PI / 2));
|
||||
}
|
||||
else if (Kind == RotationPolicyKind.Fixed)
|
||||
AddBase(Start);
|
||||
else
|
||||
{
|
||||
var lastIndex = System.Math.Floor((End - Start) / Step + 1e-7);
|
||||
if (!double.IsFinite(lastIndex))
|
||||
throw new InvalidOperationException("The sweep grid exceeds finite numeric range.");
|
||||
var count = (int)System.Math.Min(lastIndex + 1, maxSamples);
|
||||
for (var sample = 0; sample < count; sample++)
|
||||
{
|
||||
var index = count == 1 ? 0
|
||||
: System.Math.Round(lastIndex * (sample / (double)(count - 1)));
|
||||
AddBase(Start + index * Step);
|
||||
}
|
||||
}
|
||||
return angles;
|
||||
|
||||
void AddBase(double angle)
|
||||
{
|
||||
AddAngle(angles, angle);
|
||||
if (Allow180Equivalent)
|
||||
AddAngle(angles, angle + System.Math.PI);
|
||||
}
|
||||
}
|
||||
|
||||
internal void AddAngle(List<double> angles, double angle)
|
||||
{
|
||||
var fullTurn = 2 * System.Math.PI;
|
||||
angle %= fullTurn;
|
||||
if (angle < 0)
|
||||
angle += fullTurn;
|
||||
if (angle >= fullTurn)
|
||||
angle = 0;
|
||||
if (!Allows(angle))
|
||||
return;
|
||||
foreach (var existing in angles)
|
||||
if (AnglesEqual(existing, angle, 1e-7))
|
||||
return;
|
||||
angles.Add(angle);
|
||||
}
|
||||
|
||||
/// <summary>True when a placement rotation satisfies this policy. Fixed and bounded
|
||||
/// policies compare orientations modulo full turns; an allowed 180° equivalent is included.</summary>
|
||||
public bool Allows(double rotation)
|
||||
|
||||
Reference in New Issue
Block a user