feat(cutting): propose hole paths toward the perimeter entry

CuttingHoleOrder proposes the cut order of a part's remaining holes as
an OPEN path arrival -> every hole centre exactly once -> the fixed
perimeter entry, reusing CuttingPartOrder's bounded nearest-neighbour +
2-opt/Or-opt machinery (no exhaustive permutations, no new routing
invention). A proposal only: the search still certifies every rapid and
emitted lead; nothing is installed into the search yet.

CuttingPartOrder gains an optional endpoint: the terminal edge joins
EVERY improvement delta (2-opt tail edge on both sides; OrOpt gap to the
open end measures the endpoint distance), so heuristics see route moves
that re-point the path at the entry rather than only final scoring. No
endpoint keeps whole-part semantics byte-identical (control test);
precedence, prerequisites and tie policy unchanged.

Tests: empty/one hole; asymmetric fixture where the endpoint changes the
ORDER (open route ends at C, entry-facing route ends at B) with each
route asserted equal to its brute-force optimum computed from
coordinates; permutation integrity excluding the perimeter; random 7-set
within 1.05x brute force; deterministic tie order; cancellation; legacy
no-endpoint equality. Mutants dropping the terminal edge from OrOpt or
TwoOpt deltas are killed.
This commit is contained in:
aj committed 2026-10-07 11:43:26 -04:00
1 parent d4544ea358
commit fa3c5e348e
3 files changed
+231 -10

No files matched your search

@@ -0,0 +1,45 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// Proposes the ORDER in which a part's remaining holes are cut when the perimeter entry is
/// already chosen: an open path from the tool's arrival through every hole centre and ON to
/// the selected perimeter entry — the terminal edge is real rapid travel and is optimised,
/// not scored after the fact. It reuses <see cref="CuttingPartOrder"/>'s bounded nearest-
/// neighbour + 2-opt/Or-opt machinery (no exhaustive permutations) with an optional fixed
/// endpoint, so whole-part routing without an endpoint keeps byte-identical behaviour.
/// This is a proposal only: the search still certifies every rapid and emitted lead along
/// it, exactly as it does for part orders.
/// </summary>
internal static class CuttingHoleOrder
{
/// <summary>
/// Orders <paramref name="holes"/> (stable ordinals, each appearing exactly once in the
/// result, never the perimeter) to minimize arrival -&gt; holes -&gt; <paramref
/// name="perimeterEntry"/> distance. <paramref name="centresByOrdinal"/> supplies one
/// representative point per hole ordinal. Empty holes give an empty order.
/// </summary>
internal static IReadOnlyList<int> Plan(IReadOnlyList<int> holes,
IReadOnlyList<Vector> centresByOrdinal, Vector arrival, Vector perimeterEntry,
CancellationToken token = default)
{
if (holes == null)
throw new ArgumentException("Hole ordinals are required.", nameof(holes));
if (centresByOrdinal == null)
throw new ArgumentException("Hole centres are required.", nameof(centresByOrdinal));
token.ThrowIfCancellationRequested();
if (holes.Count == 0)
return Array.Empty<int>();
var none = Array.Empty<int>();
var prerequisites = holes.Select(_ => (IReadOnlyCollection<int>)none).ToList();
var centres = holes.Select(hole => centresByOrdinal[hole]).ToList();
var route = CuttingPartOrder.Plan(centres, arrival, prerequisites, token, perimeterEntry);
return route.Select(index => holes[index]).ToArray();
}
}
@@ -39,8 +39,13 @@ internal static class CuttingPartOrder
/// <param name="centres">One representative point per part ordinal.</param>
/// <param name="start">The modeled tool position before the first part.</param>
/// <param name="prerequisites">Ordinals that must come before each part; must be acyclic.</param>
/// <param name="endpoint">
/// Optional fixed final position AFTER the last visited point (an open path with a closed
/// terminal edge). The terminal edge joins EVERY improvement delta, not just final
/// scoring. Null keeps the previous whole-part open-route semantics exactly.
/// </param>
internal static int[] Plan(IReadOnlyList<Vector> centres, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token)
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token, Vector? endpoint = null)
{
var count = centres.Count;
var order = NearestNeighbour(centres, start, prerequisites, token);
@@ -48,8 +53,8 @@ internal static class CuttingPartOrder
for (var pass = 0; pass < MaxPasses; pass++)
{
token.ThrowIfCancellationRequested();
var improved = TwoOpt(order, centres, start, prerequisites, position, token);
improved |= OrOpt(order, centres, start, prerequisites, position, token);
var improved = TwoOpt(order, centres, start, prerequisites, position, token, endpoint);
improved |= OrOpt(order, centres, start, prerequisites, position, token, endpoint);
if (!improved)
break;
}
@@ -90,19 +95,23 @@ internal static class CuttingPartOrder
// Reverses order[i..j] when that shortens the open path and keeps every prerequisite earlier.
private static bool TwoOpt(int[] order, IReadOnlyList<Vector> centres, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, int[] position, CancellationToken token)
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, int[] position, CancellationToken token,
Vector? endpoint = null)
{
var improved = false;
var count = order.Length;
// The terminal edge belongs to every delta: reversing the route's tail swaps which
// endpoint-side centre faces the fixed final position.
double Tail(Vector from) => endpoint is { } e ? from.DistanceTo(e) : 0;
for (var i = 0; i < count - 1; i++)
{
token.ThrowIfCancellationRequested();
for (var j = i + 1; j < count; j++)
{
var before = Point(i - 1).DistanceTo(centres[order[i]])
+ (j + 1 < count ? centres[order[j]].DistanceTo(centres[order[j + 1]]) : 0);
+ (j + 1 < count ? centres[order[j]].DistanceTo(centres[order[j + 1]]) : Tail(centres[order[j]]));
var after = Point(i - 1).DistanceTo(centres[order[j]])
+ (j + 1 < count ? centres[order[i]].DistanceTo(centres[order[j + 1]]) : 0);
+ (j + 1 < count ? centres[order[i]].DistanceTo(centres[order[j + 1]]) : Tail(centres[order[i]]));
if (after >= before - Epsilon || !CanReverse(order, i, j, prerequisites, position))
continue;
Array.Reverse(order, i, j - i + 1);
@@ -129,7 +138,8 @@ internal static class CuttingPartOrder
// Moves a run of one to three parts to a later or earlier gap when that shortens the path.
private static bool OrOpt(int[] order, IReadOnlyList<Vector> centres, Vector start,
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, int[] position, CancellationToken token)
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, int[] position, CancellationToken token,
Vector? endpoint = null)
{
var improved = false;
var count = order.Length;
@@ -157,12 +167,15 @@ internal static class CuttingPartOrder
}
return improved;
// Path length between order[a] and order[b] (a == -1 is the start; b == count is the open end).
// Path length between order[a] and order[b] (a == -1 is the start; b == count is the open
// end — the fixed endpoint when one was supplied, so the terminal edge is in every delta).
double Gap(int a, int b)
{
if (b >= count || b < 0)
return 0;
var from = a < 0 ? start : centres[order[a]];
if (b >= count)
return endpoint is { } e ? from.DistanceTo(e) : 0;
if (b < 0)
return 0;
return from.DistanceTo(centres[order[b]]);
}
}