From fa3c5e348e8b35035cf245196928de2a3a3853df Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 7 Oct 2026 11:43:26 -0400 Subject: [PATCH] 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. --- .../CuttingPlanning/CuttingHoleOrder.cs | 45 +++++ .../CuttingPlanning/CuttingPartOrder.cs | 33 ++-- .../CuttingPlanning/CuttingHoleOrderTests.cs | 163 ++++++++++++++++++ 3 files changed, 231 insertions(+), 10 deletions(-) create mode 100644 OpenNest.Engine/CuttingPlanning/CuttingHoleOrder.cs create mode 100644 OpenNest.Tests/CuttingPlanning/CuttingHoleOrderTests.cs diff --git a/OpenNest.Engine/CuttingPlanning/CuttingHoleOrder.cs b/OpenNest.Engine/CuttingPlanning/CuttingHoleOrder.cs new file mode 100644 index 0000000..e716ea4 --- /dev/null +++ b/OpenNest.Engine/CuttingPlanning/CuttingHoleOrder.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using OpenNest.Geometry; + +namespace OpenNest.Engine.CuttingPlanning; + +/// +/// 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 '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. +/// +internal static class CuttingHoleOrder +{ + /// + /// Orders (stable ordinals, each appearing exactly once in the + /// result, never the perimeter) to minimize arrival -> holes -> distance. supplies one + /// representative point per hole ordinal. Empty holes give an empty order. + /// + internal static IReadOnlyList Plan(IReadOnlyList holes, + IReadOnlyList 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(); + + var none = Array.Empty(); + var prerequisites = holes.Select(_ => (IReadOnlyCollection)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(); + } +} diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs b/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs index 2c4b280..6b3efbd 100644 --- a/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs +++ b/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs @@ -39,8 +39,13 @@ internal static class CuttingPartOrder /// One representative point per part ordinal. /// The modeled tool position before the first part. /// Ordinals that must come before each part; must be acyclic. + /// + /// 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. + /// internal static int[] Plan(IReadOnlyList centres, Vector start, - IReadOnlyList> prerequisites, CancellationToken token) + IReadOnlyList> 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 centres, Vector start, - IReadOnlyList> prerequisites, int[] position, CancellationToken token) + IReadOnlyList> 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 centres, Vector start, - IReadOnlyList> prerequisites, int[] position, CancellationToken token) + IReadOnlyList> 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]]); } } diff --git a/OpenNest.Tests/CuttingPlanning/CuttingHoleOrderTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingHoleOrderTests.cs new file mode 100644 index 0000000..692a975 --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/CuttingHoleOrderTests.cs @@ -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; + +/// +/// 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. +/// +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 route, IReadOnlyList 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 holes, IReadOnlyList centres, + Vector arrival, Vector? endpoint) + { + double Best(IEnumerable 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(), 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)Array.Empty()).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(); + + 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(() => + 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(); + var prerequisites = centres.Select(_ => (IReadOnlyCollection)none).ToList(); + + var legacy = CuttingPartOrder.Plan(centres, Arrival, prerequisites, default); + var extended = CuttingPartOrder.Plan(centres, Arrival, prerequisites, default, endpoint: null); + + Assert.Equal(legacy, extended); + } +}