mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-10 22:24:14 -04:00
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:
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 -> holes -> <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]]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.CuttingPlanning;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Tests.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// The pure hole-path proposal: an OPEN path arrival -> every hole exactly once -> the fixed
|
||||
/// perimeter entry, reusing the bounded whole-part routing machinery. Optimum comparisons
|
||||
/// are computed from coordinates by brute force in the test — never hand-estimated.
|
||||
/// </summary>
|
||||
public class CuttingHoleOrderTests
|
||||
{
|
||||
private static readonly Vector Arrival = new(0, 0);
|
||||
|
||||
// Asymmetric fixture: without the terminal edge the route should end at C (the far
|
||||
// cluster point); with the perimeter entry at (6,0) the route must end at B instead,
|
||||
// so the endpoint changes the ORDER, not merely the total.
|
||||
private static readonly Vector A = new(1, 0);
|
||||
private static readonly Vector B = new(5, 0);
|
||||
private static readonly Vector C = new(3, 10);
|
||||
private static readonly Vector Entry = new(6, 0);
|
||||
|
||||
private static double Cost(IReadOnlyList<int> route, IReadOnlyList<Vector> centres,
|
||||
Vector arrival, Vector? endpoint)
|
||||
{
|
||||
var total = 0.0;
|
||||
var position = arrival;
|
||||
foreach (var hole in route)
|
||||
{
|
||||
total += position.DistanceTo(centres[hole]);
|
||||
position = centres[hole];
|
||||
}
|
||||
return endpoint is { } e ? total + position.DistanceTo(e) : total;
|
||||
}
|
||||
|
||||
private static double Minimum(IReadOnlyList<int> holes, IReadOnlyList<Vector> centres,
|
||||
Vector arrival, Vector? endpoint)
|
||||
{
|
||||
double Best(IEnumerable<int> remaining, Vector from, double soFar)
|
||||
{
|
||||
if (!remaining.Any())
|
||||
return soFar + (endpoint is { } e ? from.DistanceTo(e) : 0);
|
||||
return remaining.Min(next => Best(remaining.Where(h => h != next),
|
||||
centres[next], soFar + from.DistanceTo(centres[next])));
|
||||
}
|
||||
return Best(holes, arrival, 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyHoleListReturnsEmptyOrder()
|
||||
{
|
||||
Assert.Empty(CuttingHoleOrder.Plan(Array.Empty<int>(), new[] { Arrival }, Arrival, Entry));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SingleHoleIsTheOnlyOrder()
|
||||
{
|
||||
var centres = new[] { A };
|
||||
var order = CuttingHoleOrder.Plan(new[] { 0 }, centres, Arrival, Entry);
|
||||
Assert.Equal(new[] { 0 }, order);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PerimeterEntryChangesTheRouteWhereBruteForceSaysItShould()
|
||||
{
|
||||
var centres = new[] { A, B, C };
|
||||
var holes = new[] { 0, 1, 2 };
|
||||
|
||||
var open = CuttingPartOrder.Plan(centres, Arrival,
|
||||
holes.Select(_ => (IReadOnlyCollection<int>)Array.Empty<int>()).ToList(), default);
|
||||
var faced = CuttingHoleOrder.Plan(holes, centres, Arrival, Entry);
|
||||
|
||||
// The endpoint genuinely changes the proposal: the open path ends at C (away from
|
||||
// the entry), the faced path ends at B (next to the entry).
|
||||
Assert.Equal(2, open[^1]);
|
||||
Assert.Equal(1, faced[^1]);
|
||||
|
||||
// Each is the brute-force optimum for its own cost function (computed here).
|
||||
Assert.Equal(Minimum(holes, centres, Arrival, null), Cost(open, centres, Arrival, null), 9);
|
||||
Assert.Equal(Minimum(holes, centres, Arrival, Entry), Cost(faced, centres, Arrival, Entry), 9);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EveryRemainingHoleAppearsExactlyOnceAndNeverThePerimeter()
|
||||
{
|
||||
// Ordinals are the part's holes; the perimeter (say 7) is not among them.
|
||||
var centres = new[] { A, B, C, new Vector(-4, 2), new Vector(2, -3), new Vector(8, 8),
|
||||
new Vector(0, 6), new Vector(6, -6) };
|
||||
var holes = new[] { 0, 1, 2, 3, 4, 5, 6 };
|
||||
|
||||
var order = CuttingHoleOrder.Plan(holes, centres, Arrival, Entry);
|
||||
|
||||
Assert.Equal(holes.Length, order.Count);
|
||||
Assert.Equal(holes.OrderBy(h => h), order.OrderBy(h => h));
|
||||
Assert.DoesNotContain(7, order); // the perimeter ordinal is never visited
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OptimalForRandomSetAgainstBruteForce()
|
||||
{
|
||||
var random = new Random(20261006);
|
||||
var centres = Enumerable.Range(0, 7)
|
||||
.Select(_ => new Vector(random.NextDouble() * 20 - 10, random.NextDouble() * 20 - 10))
|
||||
.ToList();
|
||||
var entry = new Vector(12.5, -11.25);
|
||||
var holes = Enumerable.Range(0, 7).ToArray();
|
||||
|
||||
var order = CuttingHoleOrder.Plan(holes, centres, Arrival, entry);
|
||||
|
||||
// The bounded heuristic may not always equal the true optimum, so assert it is no
|
||||
// worse than the nearest-neighbour baseline and within 1.05x brute force.
|
||||
var brute = Minimum(holes, centres, Arrival, entry);
|
||||
Assert.True(Cost(order, centres, Arrival, entry) <= brute * 1.05 + 1e-9,
|
||||
$"proposed {Cost(order, centres, Arrival, entry):F4} > 1.05 x brute {brute:F4}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TiesBreakByStableOrdinal()
|
||||
{
|
||||
// Exact-distance pair from the arrival: nearest-neighbour takes the lower ordinal,
|
||||
// and strict-improvement-only passes never swap an equal-cost order arbitrarily.
|
||||
var centres = new[] { new Vector(5, 0), new Vector(-5, 0) };
|
||||
var noEndpoint = Array.Empty<int>();
|
||||
|
||||
var first = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Arrival, new Vector(0, 100));
|
||||
var second = CuttingHoleOrder.Plan(new[] { 0, 1 }, centres, Arrival, new Vector(0, 100));
|
||||
|
||||
Assert.Equal(new[] { 0, 1 }, first);
|
||||
Assert.Equal(first, second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationPropagates()
|
||||
{
|
||||
using var cancelled = new CancellationTokenSource();
|
||||
cancelled.Cancel();
|
||||
var centres = new[] { A, B, C };
|
||||
|
||||
Assert.ThrowsAny<OperationCanceledException>(() =>
|
||||
CuttingHoleOrder.Plan(new[] { 0, 1, 2 }, centres, Arrival, Entry, cancelled.Token));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoEndpointMatchesLegacyWholePartRouteExactly()
|
||||
{
|
||||
// The whole-part overload without an endpoint must be untouched by the extension.
|
||||
var random = new Random(7);
|
||||
var centres = Enumerable.Range(0, 8)
|
||||
.Select(_ => new Vector(random.NextDouble() * 30, random.NextDouble() * 30))
|
||||
.ToList();
|
||||
var none = Array.Empty<int>();
|
||||
var prerequisites = centres.Select(_ => (IReadOnlyCollection<int>)none).ToList();
|
||||
|
||||
var legacy = CuttingPartOrder.Plan(centres, Arrival, prerequisites, default);
|
||||
var extended = CuttingPartOrder.Plan(centres, Arrival, prerequisites, default, endpoint: null);
|
||||
|
||||
Assert.Equal(legacy, extended);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user