From ec5f57171ff7c9410e1a720cb22d417a566d062e Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 25 Sep 2026 08:33:52 -0400 Subject: [PATCH] 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 Co-Authored-By: Claude Opus 5.5 --- .../Jobs/RotationCandidatesTests.cs | 166 ++++++++++++++++++ .../Jobs/RotationPolicyTests.cs | 116 ++++++++++++ OpenNest.Engine/Jobs/RotationCandidates.cs | 157 +++++++++++++++++ OpenNest.Engine/Jobs/RotationPolicy.cs | 67 +++++++ 4 files changed, 506 insertions(+) create mode 100644 OpenNest.Engine.Tests/Jobs/RotationCandidatesTests.cs create mode 100644 OpenNest.Engine.Tests/Jobs/RotationPolicyTests.cs create mode 100644 OpenNest.Engine/Jobs/RotationCandidates.cs diff --git a/OpenNest.Engine.Tests/Jobs/RotationCandidatesTests.cs b/OpenNest.Engine.Tests/Jobs/RotationCandidatesTests.cs new file mode 100644 index 0000000..e99e55a --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/RotationCandidatesTests.cs @@ -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(() => + RotationCandidates.ForShape(RotationPolicy.Automatic, Rectangle(), -1)); + foreach (var tolerance in new[] { 0, -1, double.NaN, double.PositiveInfinity }) + Assert.Throws(() => + RotationCandidates.DistinctOutlines(Rectangle(), new[] { 0.0 }, tolerance)); + Assert.Throws(() => RotationCandidates.ForShape(null!, Rectangle())); + Assert.Throws(() => RotationCandidates.ForShape(RotationPolicy.Automatic, null!)); + Assert.Throws(() => 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; + } +} diff --git a/OpenNest.Engine.Tests/Jobs/RotationPolicyTests.cs b/OpenNest.Engine.Tests/Jobs/RotationPolicyTests.cs new file mode 100644 index 0000000..0f9ee58 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/RotationPolicyTests.cs @@ -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(() => + 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)); + }); + } +} diff --git a/OpenNest.Engine/Jobs/RotationCandidates.cs b/OpenNest.Engine/Jobs/RotationCandidates.cs new file mode 100644 index 0000000..f4cb378 --- /dev/null +++ b/OpenNest.Engine/Jobs/RotationCandidates.cs @@ -0,0 +1,157 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Jobs; + +/// Deterministic rotation candidates and optional perimeter symmetry reduction. +public static class RotationCandidates +{ + /// + /// 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 , 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. + /// + /// An argument is null. + /// The limit is negative. + public static IReadOnlyList 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(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(); + } + + /// + /// 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. + /// + /// An argument is null. + /// Tolerance is not finite and positive. + /// The perimeter is not usable closed geometry. + public static IReadOnlyList DistinctOutlines( + Shape perimeter, IEnumerable 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(); + foreach (var angle in angles) + RotationPolicy.Automatic.AddAngle(candidates, angle); + var result = new List(); + var outlines = new List>(); + 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 first, List 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 points, List 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; + } +} diff --git a/OpenNest.Engine/Jobs/RotationPolicy.cs b/OpenNest.Engine/Jobs/RotationPolicy.cs index 0621c83..03d9fb4 100644 --- a/OpenNest.Engine/Jobs/RotationPolicy.cs +++ b/OpenNest.Engine/Jobs/RotationPolicy.cs @@ -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); + /// + /// 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 . + /// + /// Positive cap on base sweep samples, before deduplication. + /// The sample cap is not positive. + /// The sweep grid exceeds finite numeric range. + public IReadOnlyList EnumerateAngles(int maxSamples = 720) + { + if (maxSamples < 1) + throw new ArgumentOutOfRangeException(nameof(maxSamples)); + + var angles = new List(); + 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 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); + } + /// True when a placement rotation satisfies this policy. Fixed and bounded /// policies compare orientations modulo full turns; an allowed 180° equivalent is included. public bool Allows(double rotation)