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
+157
View File
@@ -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;
}
}
+67
View File
@@ -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)