diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs b/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs
new file mode 100644
index 0000000..2c4b280
--- /dev/null
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPartOrder.cs
@@ -0,0 +1,190 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using OpenNest.Geometry;
+
+namespace OpenNest.Engine.CuttingPlanning;
+
+///
+/// Proposes a whole-part visiting order as an open travelling-salesman path from the start point
+/// over part centres: nearest neighbour, then 2-opt and Or-opt improvement, never placing a part
+/// before one of its prerequisites. It is only a proposal; the forward search still checks every
+/// rapid and lead along it.
+///
+internal static class CuttingPartOrder
+{
+ private const double Epsilon = 1e-9;
+
+ /// Upper bound on improvement passes, so a large plate cannot loop for long.
+ internal const int MaxPasses = 50;
+
+ /// The part ordinals to order; prerequisites outside this set count as done.
+ /// One representative point per part ordinal.
+ /// The modeled tool position before the first of these parts.
+ /// Ordinals that must come before each part; must be acyclic.
+ internal static int[] Plan(IReadOnlyList parts, IReadOnlyList centres, Vector start,
+ IReadOnlyList> prerequisites, CancellationToken token)
+ {
+ var local = new Dictionary();
+ for (var i = 0; i < parts.Count; i++)
+ local.Add(parts[i], i);
+ var localCentres = parts.Select(p => centres[p]).ToArray();
+ var localPrerequisites = parts
+ .Select(p => (IReadOnlyCollection)prerequisites[p].Where(local.ContainsKey).Select(q => local[q]).ToArray())
+ .ToArray();
+ return Plan(localCentres, start, localPrerequisites, token).Select(i => parts[i]).ToArray();
+ }
+
+ /// One representative point per part ordinal.
+ /// The modeled tool position before the first part.
+ /// Ordinals that must come before each part; must be acyclic.
+ internal static int[] Plan(IReadOnlyList centres, Vector start,
+ IReadOnlyList> prerequisites, CancellationToken token)
+ {
+ var count = centres.Count;
+ var order = NearestNeighbour(centres, start, prerequisites, token);
+ var position = new int[count];
+ 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);
+ if (!improved)
+ break;
+ }
+ return order;
+ }
+
+ private static int[] NearestNeighbour(IReadOnlyList centres, Vector start,
+ IReadOnlyList> prerequisites, CancellationToken token)
+ {
+ var count = centres.Count;
+ var placed = new bool[count];
+ var order = new int[count];
+ var current = start;
+ for (var step = 0; step < count; step++)
+ {
+ token.ThrowIfCancellationRequested();
+ var best = -1;
+ var bestDistance = double.MaxValue;
+ for (var part = 0; part < count; part++)
+ {
+ if (placed[part] || prerequisites[part].Any(p => !placed[p]))
+ continue;
+ var distance = current.DistanceTo(centres[part]);
+ if (distance < bestDistance - Epsilon)
+ {
+ best = part;
+ bestDistance = distance;
+ }
+ }
+ if (best < 0)
+ throw new InvalidOperationException("Part prerequisites form a cycle.");
+ placed[best] = true;
+ order[step] = best;
+ current = centres[best];
+ }
+ return order;
+ }
+
+ // 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)
+ {
+ var improved = false;
+ var count = order.Length;
+ 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);
+ var after = Point(i - 1).DistanceTo(centres[order[j]])
+ + (j + 1 < count ? centres[order[i]].DistanceTo(centres[order[j + 1]]) : 0);
+ if (after >= before - Epsilon || !CanReverse(order, i, j, prerequisites, position))
+ continue;
+ Array.Reverse(order, i, j - i + 1);
+ improved = true;
+ }
+ }
+ return improved;
+
+ Vector Point(int index) => index < 0 ? start : centres[order[index]];
+ }
+
+ // A reversal breaks a prerequisite only when both parts lie inside the reversed span.
+ private static bool CanReverse(int[] order, int i, int j, IReadOnlyList> prerequisites,
+ int[] position)
+ {
+ for (var k = 0; k < order.Length; k++)
+ position[order[k]] = k;
+ for (var k = i; k <= j; k++)
+ foreach (var prerequisite in prerequisites[order[k]])
+ if (position[prerequisite] >= i && position[prerequisite] <= j)
+ return false;
+ return true;
+ }
+
+ // 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)
+ {
+ var improved = false;
+ var count = order.Length;
+ for (var length = 1; length <= 3; length++)
+ for (var i = 0; i + length <= count; i++)
+ {
+ token.ThrowIfCancellationRequested();
+ var last = i + length - 1;
+ var removal = Gap(i - 1, i) + Gap(last, last + 1) - Gap(i - 1, last + 1);
+ // Insert between order[gap - 1] and order[gap], outside the run.
+ for (var gap = 0; gap <= count; gap++)
+ {
+ if (gap >= i && gap <= last + 1)
+ continue;
+ var insertion = Gap(gap - 1, i) + Gap(last, gap) - Gap(gap - 1, gap);
+ if (insertion >= removal - Epsilon)
+ continue;
+ var candidate = Move(order, i, length, gap);
+ if (!Valid(candidate, prerequisites, position))
+ continue;
+ Array.Copy(candidate, order, count);
+ improved = true;
+ break;
+ }
+ }
+ return improved;
+
+ // Path length between order[a] and order[b] (a == -1 is the start; b == count is the open end).
+ double Gap(int a, int b)
+ {
+ if (b >= count || b < 0)
+ return 0;
+ var from = a < 0 ? start : centres[order[a]];
+ return from.DistanceTo(centres[order[b]]);
+ }
+ }
+
+ private static int[] Move(int[] order, int start, int length, int gap)
+ {
+ var run = order.Skip(start).Take(length).ToArray();
+ var rest = order.Take(start).Concat(order.Skip(start + length)).ToList();
+ var insertAt = gap > start ? gap - length : gap;
+ rest.InsertRange(insertAt, run);
+ return rest.ToArray();
+ }
+
+ internal static bool Valid(IReadOnlyList order, IReadOnlyList> prerequisites,
+ int[] position)
+ {
+ for (var k = 0; k < order.Count; k++)
+ position[order[k]] = k;
+ for (var k = 0; k < order.Count; k++)
+ foreach (var prerequisite in prerequisites[order[k]])
+ if (position[prerequisite] > k)
+ return false;
+ return true;
+ }
+}
diff --git a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
index f18b21e..a8f3086 100644
--- a/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
+++ b/OpenNest.Engine/CuttingPlanning/JointCuttingPlanSearch.cs
@@ -9,29 +9,177 @@ using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
-/// One forward DFS over whole-part selection and emitted contour prefixes.
+///
+/// Plans whole parts and their emitted contour prefixes along a part order. A preserved order is
+/// followed with full backtracking. Otherwise the order comes from ;
+/// when a part on it cannot be reached without crossing parts already cut, the search learns
+/// "cut this part before those", keeps the parts cut before them and re-plans the rest.
+///
internal static class JointCuttingPlanSearch
{
+ ///
+ /// Expansions per entry and contour that a reordering attempt may spend without getting further
+ /// along its order before it gives up and learns from the part that blocked it.
+ ///
+ internal const int StallExpansionsPerEntry = 8;
+
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList Order,
IReadOnlyList Findings, int Expansions);
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
{
- var expansions = 0;
- var rejected = new List();
- var materials = snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray();
- var stack = new Stack();
- stack.Push(new(new([], snapshot.StartPoint, new ReleasedContourState(), null)));
+ var walk = new Walk(snapshot, token);
+ var count = snapshot.Placements.Count;
try
{
+ if (snapshot.PreservePartOrder)
+ {
+ var kept = walk.Follow(Enumerable.Range(0, count).ToArray(), null, null);
+ return kept.Order != null ? walk.Ready(kept.Order) : walk.Exhausted();
+ }
+
+ var centres = snapshot.Placements.Select(Centre).ToArray();
+ var prerequisites = Enumerable.Range(0, count)
+ .Select(i => new HashSet(snapshot.Dependencies.PrerequisitesOf(i))).ToArray();
+ var maxContours = snapshot.Placements.Max(p => p.Prepared?.Count ?? 1);
+ var stall = StallExpansionsPerEntry * snapshot.MaxEntries * maxContours;
+ var sequence = CuttingPartOrder.Plan(Enumerable.Range(0, count).ToArray(), centres,
+ snapshot.StartPoint, prerequisites, token);
+ Node resume = null;
+ while (true)
+ {
+ var attempt = walk.Follow(sequence, stall, resume);
+ if (attempt.Order != null)
+ return walk.Ready(attempt.Order);
+ if (!Learn(attempt, prerequisites))
+ return walk.Exhausted();
+ // Back up to just before the earliest part the blocked approach crossed, keep the
+ // parts cut before it, and re-plan the rest from where the tool is at that point.
+ var back = attempt.Crossed.Min(part => Array.IndexOf(sequence, part));
+ resume = attempt.BoundaryAt(back);
+ sequence = [.. sequence.Take(back), .. CuttingPartOrder.Plan(sequence[back..], centres,
+ resume.Position, prerequisites, token)];
+ }
+ }
+ catch (BudgetExceededException)
+ {
+ return new(CuttingPlanStatus.NoSolutionWithinBudget, [], walk.Rejected, walk.Expansions);
+ }
+ catch (OperationCanceledException)
+ {
+ return new(CuttingPlanStatus.Cancelled, [], [], walk.Expansions);
+ }
+ }
+
+ // "Cut the blocked part before every part whose cut contour its approach crossed", unless that
+ // would contradict an order already required. False when nothing new was learned.
+ private static bool Learn(Attempt attempt, HashSet[] prerequisites)
+ {
+ if (attempt.Blocked is not int blocked)
+ return false;
+ var learned = false;
+ foreach (var crossed in attempt.Crossed.Order())
+ {
+ if (crossed == blocked || prerequisites[crossed].Contains(blocked) || Precedes(crossed, blocked, prerequisites))
+ continue;
+ prerequisites[crossed].Add(blocked);
+ learned = true;
+ }
+ return learned;
+ }
+
+ // True when 'first' must already come before 'second' through the prerequisite chain.
+ private static bool Precedes(int first, int second, HashSet[] prerequisites)
+ {
+ var seen = new HashSet();
+ var pending = new Stack(prerequisites[second]);
+ while (pending.Count != 0)
+ {
+ var part = pending.Pop();
+ if (part == first)
+ return true;
+ if (seen.Add(part))
+ foreach (var prerequisite in prerequisites[part])
+ pending.Push(prerequisite);
+ }
+ return false;
+ }
+
+ // The centre of the part's placed cut material, for ordering only.
+ private static Vector Centre(FixedProgramPlacement placement)
+ {
+ var cuts = placement.Execution.Motions
+ .Where(m => !m.Rapid && m.Layer is LayerType.Cut or LayerType.Display && m.Curve != null)
+ .Select(m => m.Curve.ToEntity().BoundingBox).ToList();
+ return cuts.Count == 0 ? placement.Execution.DeparturePoint : cuts.GetBoundingBox().Center;
+ }
+
+ internal static CuttingPlanFinding Finding(FixedProgramPlacement source, PostVerificationKind? kind, string message) =>
+ new(source.SourceOrdinal, source.SourcePart, null, null, kind, message);
+
+ internal static IEnumerable Map(CuttingPlanSnapshot snapshot, IEnumerable findings) =>
+ findings.Select(f =>
+ {
+ var source = f.PartNumber is { } p ? snapshot.Placements[p - 1] : null;
+ var other = f.OtherPartNumber is { } o ? snapshot.Placements[o - 1] : null;
+ return new CuttingPlanFinding(source?.SourceOrdinal, source?.SourcePart,
+ other?.SourceOrdinal, other?.SourcePart, f.Kind, f.Message);
+ });
+
+ ///
+ /// The forward DFS over the next part on a given order and its emitted contour prefixes.
+ /// Attempts share one expansion budget and one list of rejected findings.
+ ///
+ private sealed class Walk(CuttingPlanSnapshot snapshot, CancellationToken token)
+ {
+ private readonly List rejected = [];
+ private readonly LeadMaterialSnapshot[] materials =
+ snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray();
+
+ internal int Expansions { get; private set; }
+
+ internal IReadOnlyList Rejected => rejected.Distinct().ToArray();
+
+ internal Outcome Ready(IReadOnlyList order) =>
+ new(CuttingPlanStatus.Ready, order, [], Expansions);
+
+ // Entries are capped; exhaustion is not a proof over all possible entries.
+ internal Outcome Exhausted()
+ {
+ var status = snapshot.Placements.Any(p => p.Prepared != null)
+ ? CuttingPlanStatus.NoSolutionWithinBudget
+ : rejected.Any(f => f.Kind == PostVerificationKind.Incomplete)
+ ? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict;
+ return new(status, [], Rejected, Expansions);
+ }
+
+ ///
+ /// Follows from (or the start point).
+ /// With a stall limit the attempt ends once that many expansions pass without getting
+ /// further along the order; it never backtracks behind its starting node.
+ ///
+ internal Attempt Follow(int[] sequence, int? stall, Node resume)
+ {
+ var root = resume ?? new Node([], snapshot.StartPoint, new ReleasedContourState(), null, null);
+ var attempt = new Attempt(sequence);
+ var progressExpansions = Expansions;
+ var stack = new Stack();
+ stack.Push(new(root));
while (stack.Count != 0)
{
token.ThrowIfCancellationRequested();
var frame = stack.Peek();
var node = frame.Node;
if (node.Order.Length == snapshot.Placements.Count)
- return new(CuttingPlanStatus.Ready, node.Order, [], expansions);
- frame.Children ??= Expand(node).OrderBy(c => c.Distance)
+ {
+ attempt.Order = node.Order;
+ return attempt;
+ }
+ if (attempt.Advance(node))
+ progressExpansions = Expansions;
+ else if (stall is int limit && Expansions - progressExpansions > limit)
+ return attempt;
+ frame.Children ??= Expand(node, sequence, attempt).OrderBy(c => c.Distance)
.ThenBy(c => c.Ordinal).ThenBy(c => c.Contour).ThenBy(c => c.Entry).ToArray();
if (frame.Next == frame.Children.Length)
{
@@ -40,29 +188,20 @@ internal static class JointCuttingPlanSearch
}
stack.Push(new(frame.Children[frame.Next++].Node));
}
- // Entries are capped; exhaustion is not a proof over all possible entries.
- var status = snapshot.Placements.Any(p => p.Prepared != null)
- ? CuttingPlanStatus.NoSolutionWithinBudget
- : rejected.Any(f => f.Kind == PostVerificationKind.Incomplete)
- ? CuttingPlanStatus.UnsupportedGeometry : CuttingPlanStatus.ConstraintConflict;
- return new(status, [], rejected.Distinct().ToArray(), expansions);
- }
- catch (BudgetExceededException)
- {
- return new(CuttingPlanStatus.NoSolutionWithinBudget, [], rejected.Distinct().ToArray(), expansions);
- }
- catch (OperationCanceledException)
- {
- return new(CuttingPlanStatus.Cancelled, [], [], expansions);
+ return attempt;
}
- IEnumerable Expand(Node node)
+ private IEnumerable Expand(Node node, int[] sequence, Attempt attempt)
{
- var finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet();
- var sources = node.Active is { } active ? new[] { active.Source }
- : snapshot.Placements.Where(p => !finished.Contains(p.SourceOrdinal)
- && snapshot.Dependencies.IsReady(p.SourceOrdinal, finished)
- && (!snapshot.PreservePartOrder || p.SourceOrdinal == node.Order.Length));
+ IEnumerable sources;
+ if (node.Active is { } active)
+ sources = [active.Source];
+ else
+ {
+ var finished = node.Order.Select(o => o.SourceOrdinal).ToHashSet();
+ var next = snapshot.Placements[sequence[node.Order.Length]];
+ sources = snapshot.Dependencies.IsReady(next.SourceOrdinal, finished) ? [next] : [];
+ }
foreach (var source in sources)
{
token.ThrowIfCancellationRequested();
@@ -70,8 +209,8 @@ internal static class JointCuttingPlanSearch
{
CountExpansion(source);
var checker = node.Checker.Copy();
- if (Check(source, source.Execution, node.Position, checker))
- yield return new(new([.. node.Order, source], source.Execution.DeparturePoint, checker, null),
+ if (Check(source, source.Execution, node.Position, checker, attempt))
+ yield return new(new([.. node.Order, source], source.Execution.DeparturePoint, checker, null, node),
source.Execution.RapidDistanceFrom(node.Position), source.SourceOrdinal, -1, -1);
continue;
}
@@ -79,6 +218,7 @@ internal static class JointCuttingPlanSearch
var choices = node.Active?.Choices ?? [];
var arrival = node.Active?.Arrival ?? node.Position;
var before = node.Active?.Before ?? node.Checker;
+ var boundary = node.Active?.Boundary ?? node;
var contours = choices.Length == prepared.Count - 1 ? new[] { prepared.PerimeterOrdinal }
: Enumerable.Range(0, prepared.PerimeterOrdinal).Where(c => !choices.Any(e => e.ContourOrdinal == c));
foreach (var contour in contours)
@@ -104,35 +244,38 @@ internal static class JointCuttingPlanSearch
}
// Prefix includes all earlier cuts and scribes. Replay from BEFORE the whole part.
var checker = before.Copy();
- if (!Check(source, execution, arrival, checker)) continue;
+ if (!Check(source, execution, arrival, checker, attempt)) continue;
var distance = execution.RapidDistanceFrom(arrival);
var next = prefix.Length == prepared.Count
- ? new Node([.. node.Order, source.Propose(program, execution, prefix, token)], execution.DeparturePoint, checker, null)
+ ? new Node([.. node.Order, source.Propose(program, execution, prefix, token)],
+ execution.DeparturePoint, checker, null, boundary)
: new Node(node.Order, execution.DeparturePoint, checker,
- new(source, prefix, arrival, before, distance));
+ new(source, prefix, arrival, before, distance, boundary), null);
yield return new(next, distance - (node.Active?.Distance ?? 0), source.SourceOrdinal, contour, entry);
}
}
}
}
- void CountExpansion(FixedProgramPlacement source)
+ private void CountExpansion(FixedProgramPlacement source)
{
token.ThrowIfCancellationRequested();
- if (expansions == snapshot.ExpansionBudget)
+ if (Expansions == snapshot.ExpansionBudget)
{
rejected.Add(Finding(source, null, $"Expansion budget {snapshot.ExpansionBudget} reached before the next candidate."));
throw new BudgetExceededException();
}
- expansions++;
- snapshot.ExpansionObserver?.Invoke(expansions);
+ Expansions++;
+ snapshot.ExpansionObserver?.Invoke(Expansions);
token.ThrowIfCancellationRequested();
}
- bool Check(FixedProgramPlacement source, OwnedExecution execution, Vector arrival, ReleasedContourState checker)
+ private bool Check(FixedProgramPlacement source, OwnedExecution execution, Vector arrival,
+ ReleasedContourState checker, Attempt attempt)
{
var findings = checker.Check(execution, arrival, source.SourceOrdinal + 1, source.IsCutOff, token);
rejected.AddRange(Map(snapshot, findings));
+ attempt.NoteCrossings(source.SourceOrdinal, findings);
// A fixed cutoff has no material or leads to certify; its rapids are still checked.
var lead = source.IsCutOff ? new LeadPathValidationResult(true, true, null)
: LeadPathValidator.Check(execution, source.Material, materials, token);
@@ -142,22 +285,63 @@ internal static class JointCuttingPlanSearch
}
}
- internal static CuttingPlanFinding Finding(FixedProgramPlacement source, PostVerificationKind? kind, string message) =>
- new(source.SourceOrdinal, source.SourcePart, null, null, kind, message);
+ /// One pass along an order: how far it got, which part stopped it and what that part crossed.
+ private sealed class Attempt(int[] sequence)
+ {
+ private long progress = -1;
- internal static IEnumerable Map(CuttingPlanSnapshot snapshot, IEnumerable findings) =>
- findings.Select(f =>
+ internal IReadOnlyList Order { get; set; }
+
+ /// The deepest whole-part boundary reached; its Previous chain leads back to the root.
+ private Node Deepest { get; set; }
+
+ /// The ordinal of the part the attempt could not get past, or null.
+ internal int? Blocked => Deepest == null || Deepest.Order.Length >= sequence.Length ? null
+ : sequence[Deepest.Order.Length];
+
+ /// Parts whose completed contours the blocked part's motions crossed (it can be among them).
+ internal HashSet Crossed { get; } = [];
+
+ // Records a node that gets further along the order than any before. True when it does.
+ internal bool Advance(Node node)
{
- var source = f.PartNumber is { } p ? snapshot.Placements[p - 1] : null;
- var other = f.OtherPartNumber is { } o ? snapshot.Placements[o - 1] : null;
- return new CuttingPlanFinding(source?.SourceOrdinal, source?.SourcePart,
- other?.SourceOrdinal, other?.SourcePart, f.Kind, f.Message);
- });
+ var depth = (long)node.Order.Length * (int.MaxValue + 1L) + (node.Active?.Choices.Length ?? 0);
+ if (depth <= progress)
+ return false;
+ progress = depth;
+ if (node.Active == null)
+ {
+ Deepest = node;
+ Crossed.Clear();
+ }
+ return true;
+ }
+
+ internal void NoteCrossings(int ordinal, IEnumerable findings)
+ {
+ if (Blocked != ordinal)
+ return;
+ foreach (var finding in findings)
+ if (finding.Kind == PostVerificationKind.RapidCrossing && finding.OtherPartNumber is int other)
+ Crossed.Add(other - 1);
+ }
+
+ /// The part boundary at on the way to the deepest one.
+ internal Node BoundaryAt(int depth)
+ {
+ var node = Deepest;
+ while (node.Order.Length > depth)
+ node = node.Previous;
+ return node;
+ }
+ }
private sealed class BudgetExceededException : Exception;
private sealed record ActivePart(FixedProgramPlacement Source, ContourChoice[] Choices, Vector Arrival,
- ReleasedContourState Before, double Distance);
- private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker, ActivePart Active);
+ ReleasedContourState Before, double Distance, Node Boundary);
+ /// A search state; Previous links a whole-part boundary to the boundary before it.
+ private sealed record Node(FixedProgramPlacement[] Order, Vector Position, ReleasedContourState Checker,
+ ActivePart Active, Node Previous);
private sealed record Edge(Node Node, double Distance, int Ordinal, int Contour, int Entry);
private sealed class Frame(Node node)
{
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPartOrderTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPartOrderTests.cs
new file mode 100644
index 0000000..e726f8f
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/CuttingPartOrderTests.cs
@@ -0,0 +1,94 @@
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+public class CuttingPartOrderTests
+{
+ private static readonly Vector[] Line = [new(1, 0), new(-2, 0), new(3, 0)];
+
+ [Fact]
+ public void Plan_ShortensTheNearestNeighbourTour()
+ {
+ // Nearest first gives 0, 2, 1 (1 + 2 + 5 = 8); going left first is 2 + 3 + 2 = 7.
+ var order = Plan(Line, NoPrerequisites(3));
+
+ Assert.Equal(new[] { 1, 0, 2 }, order);
+ }
+
+ [Fact]
+ public void Plan_NeverPlacesAPartBeforeItsPrerequisite()
+ {
+ // The shortest tour starts with part 1, but part 2 must come before it.
+ var prerequisites = NoPrerequisites(3);
+ prerequisites[1] = [2];
+
+ var order = Plan(Line, prerequisites);
+
+ Assert.Equal(new[] { 0, 2, 1 }, order);
+ }
+
+ [Fact]
+ public void Plan_WaitsForAPrerequisiteBeforeTheNearestPart()
+ {
+ // Part 0 is nearest and the shorter tour, but part 1 must be cut first.
+ var prerequisites = NoPrerequisites(2);
+ prerequisites[0] = [1];
+
+ Assert.Equal(new[] { 1, 0 }, Plan([new(1, 0), new(5, 0)], prerequisites));
+ }
+
+ [Fact]
+ public void Plan_OnASubset_TreatsPrerequisitesOutsideItAsDone()
+ {
+ var prerequisites = NoPrerequisites(3);
+ prerequisites[1] = [0];
+
+ var order = CuttingPartOrder.Plan([1, 2], Line, new Vector(4, 0), prerequisites, CancellationToken.None);
+
+ Assert.Equal(new[] { 2, 1 }, order);
+ }
+
+ [Fact]
+ public void Plan_ReversalsShortenTheTour()
+ {
+ // Nearest neighbour gives 4, 1, 0, 2, 3. With reversals the tour ends at 4, 3, 1, 0, 2
+ // (14.14); moving short runs alone stops at 2, 0, 4, 1, 3 (15.30).
+ Vector[] centres = [new(-2, -1), new(0, -2), new(-4, 3), new(2, -4), new(0, -1)];
+
+ Assert.Equal(new[] { 4, 3, 1, 0, 2 }, Plan(centres, NoPrerequisites(5)));
+ }
+
+ [Fact]
+ public void Plan_MovesARunOfPartsWhereReversalsCannotHelp()
+ {
+ // Nearest neighbour and 2-opt stop at 1, 3, 2, 0 (13.78); moving part 0 to the front
+ // gives 0, 1, 3, 2 (12.16).
+ Vector[] centres = [new(-3, 0), new(1, 0), new(4, -3), new(3, 0)];
+
+ Assert.Equal(new[] { 0, 1, 3, 2 }, Plan(centres, NoPrerequisites(4)));
+ }
+
+ [Fact]
+ public void Plan_EqualTours_PreferTheLowerOrdinalFirst()
+ {
+ Assert.Equal(new[] { 0, 1 }, Plan([new(0, 5), new(0, -5)], NoPrerequisites(2)));
+ Assert.Equal(new[] { 0, 1 }, Plan([new(0, -5), new(0, 5)], NoPrerequisites(2)));
+ }
+
+ [Fact]
+ public void Plan_PrerequisiteCycle_Throws()
+ {
+ var prerequisites = NoPrerequisites(2);
+ prerequisites[0] = [1];
+ prerequisites[1] = [0];
+
+ Assert.Throws(() => Plan([new(0, 0), new(1, 0)], prerequisites));
+ }
+
+ private static int[] Plan(Vector[] centres, IReadOnlyCollection[] prerequisites) =>
+ CuttingPartOrder.Plan(centres, Vector.Zero, prerequisites, CancellationToken.None);
+
+ private static IReadOnlyCollection[] NoPrerequisites(int count) =>
+ Enumerable.Range(0, count).Select(_ => (IReadOnlyCollection)Array.Empty()).ToArray();
+}
diff --git a/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs b/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs
new file mode 100644
index 0000000..ea65636
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/ReorderSearchTests.cs
@@ -0,0 +1,153 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Diagnostics;
+using OpenNest.Engine.CuttingPlanning;
+using OpenNest.Geometry;
+
+namespace OpenNest.Tests.CuttingPlanning;
+
+/// Whole-part order chosen by the planner itself (the part order is not preserved).
+public class ReorderSearchTests
+{
+ [Theory]
+ [InlineData(16, false)]
+ [InlineData(16, true)]
+ [InlineData(36, false)]
+ [InlineData(36, true)]
+ public void FreeOrder_DenseGrid_IsReadyWithinTheDefaultBudget(int count, bool shuffled)
+ {
+ var nest = new Nest();
+ var plate = nest.CreatePlate();
+ plate.Size = new Size(100, 100);
+ foreach (var part in Grid(count, shuffled))
+ plate.Parts.Add(part);
+ var parts = plate.Parts.ToArray();
+
+ var result = CuttingPlanService.Plan(CuttingPlanRequest.ForPlate(plate,
+ confirmedParameters: ExplicitContourTests.Parameters()));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(result.IndependentlyReplayed);
+ Assert.True(result.Expansions <= 20000);
+ Assert.Equal(parts.OrderBy(Key), result.ProposedOrder.Select(p => p.SourcePart).OrderBy(Key));
+ Assert.All(result.ProposedOrder, p => Assert.True(p.IsRegenerated));
+ Assert.Equal(parts, plate.Parts); // Planning alone never reorders the live plate.
+ }
+
+ [Fact]
+ public void FreeOrder_BlockedApproach_LearnsToCutThatPartFirst()
+ {
+ // Locked programs lead in and out on each part's left side, so leaving a cut part to the
+ // right crosses it. The shortest tour B, A, C is blocked at A (crossing B); with A before B
+ // it is A, B, C, blocked at C (crossing A and B). Only right to left is safe.
+ var a = LeftLeadRectangle("A", 4, 0, 4, 4);
+ var b = LeftLeadRectangle("B", 0, 1, 2, 2);
+ var c = LeftLeadRectangle("C", 14, 1, 2, 2);
+ Assert.Contains(Analyze(b, a, c).Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
+ Assert.Contains(Analyze(a, b, c).Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
+
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([a, b, c],
+ confirmedParameters: ExplicitContourTests.Parameters()));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.True(result.IndependentlyReplayed);
+ Assert.Equal(new[] { c, a, b }, result.ProposedOrder.Select(p => p.SourcePart));
+ Assert.All(result.ProposedOrder, p => Assert.False(p.IsRegenerated));
+ Assert.Empty(Analyze(c, a, b).Findings);
+ }
+
+ [Fact]
+ public void FreeOrder_BlockedAfterRegeneratedParts_LearnsBeforeRetryingTheirEntries()
+ {
+ // Two regenerated parts come first; then the locked trio blocks as above. Retrying every
+ // entry combination of the two parts (over a million) before learning would exhaust the budget.
+ var drawing = new Drawing("holes", PreparedContourTests.Holes());
+ var first = new Part(drawing, new Vector(1, 1));
+ var second = new Part(drawing, new Vector(12, 1));
+ var a = LeftLeadRectangle("A", 44, 0, 4, 4);
+ var b = LeftLeadRectangle("B", 40, 1, 2, 2);
+ var c = LeftLeadRectangle("C", 54, 1, 2, 2);
+
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([first, second, a, b, c],
+ confirmedParameters: ExplicitContourTests.Parameters()));
+
+ Assert.True(result.Status == CuttingPlanStatus.Ready, Describe(result));
+ Assert.Equal(new[] { first, second, c, a, b }, result.ProposedOrder.Select(p => p.SourcePart));
+ Assert.True(result.IndependentlyReplayed);
+ }
+
+ [Fact]
+ public void FreeOrder_NoSafeOrder_RefusesWithTheCrossing()
+ {
+ // Leads on the far sides: whichever part is cut first, reaching the other crosses it.
+ var left = LeftLeadRectangle("left", 0, 0, 2, 2);
+ var right = LeftLeadRectangle("right", 4, 0, 2, 2, mirror: true);
+
+ var result = CuttingPlanService.Plan(new CuttingPlanRequest([left, right],
+ confirmedParameters: ExplicitContourTests.Parameters()));
+
+ Assert.Equal(CuttingPlanStatus.ConstraintConflict, result.Status);
+ Assert.Empty(result.ProposedOrder);
+ Assert.Contains(result.Findings, f => f.Kind == PostVerificationKind.RapidCrossing);
+ }
+
+ private static Part[] Grid(int count, bool shuffled)
+ {
+ var drawing = new Drawing("grid", PreparedContourTests.Holes());
+ var side = (int)System.Math.Ceiling(System.Math.Sqrt(count));
+ var parts = Enumerable.Range(0, count)
+ .Select(i => new Part(drawing, new Vector(1 + i % side * 11, 1 + i / side * 11)))
+ .ToArray();
+ if (!shuffled)
+ return parts;
+ var random = new Random(7);
+ return parts.OrderBy(_ => random.Next()).ToArray();
+ }
+
+ private static (double, double) Key(Part part) => (part.Location.X, part.Location.Y);
+
+ // A locked rectangle whose lead-in and lead-out sit 0.25 outside its left edge (its right
+ // edge when mirrored), so the tool departs on that side.
+ private static Part LeftLeadRectangle(string name, double x, double y, double width, double height,
+ bool mirror = false)
+ {
+ var clean = LeadPathValidationTests.Rectangle(0, 0, width, height);
+ var part = new Part(new Drawing(name, clean), new Vector(x, y));
+ var edge = mirror ? width : 0;
+ var outside = mirror ? width + 0.25 : -0.25;
+ var placed = new Program();
+ placed.MoveTo(outside, height / 2);
+ placed.Codes.Add(new LinearMove(edge, height / 2) { Layer = LayerType.Leadin });
+ // Same direction as the clean outline, which runs clockwise from its corner at the origin.
+ if (mirror)
+ {
+ placed.LineTo(width, 0); placed.LineTo(0, 0); placed.LineTo(0, height);
+ placed.LineTo(width, height);
+ }
+ else
+ {
+ placed.LineTo(0, height); placed.LineTo(width, height); placed.LineTo(width, 0);
+ placed.LineTo(0, 0);
+ }
+ placed.LineTo(edge, height / 2);
+ placed.Codes.Add(new LinearMove(outside, height / 2) { Layer = LayerType.Leadout });
+ Assert.True(part.RestoreLeadInProgram(placed, true));
+ return part;
+ }
+
+ private static PostVerificationReport Analyze(params Part[] parts)
+ {
+ var nest = new Nest();
+ var plate = nest.CreatePlate();
+ foreach (var source in parts)
+ {
+ var copy = new Part(new Drawing("replay", (Program)source.BaseDrawing.Program.Clone()), source.Location);
+ Assert.True(copy.RestoreLeadInProgram((Program)source.Program.Clone(), source.LeadInsLocked));
+ plate.Parts.Add(copy);
+ }
+ return PostVerificationAnalyzer.Analyze(nest, Vector.Zero);
+ }
+
+ private static string Describe(CuttingPlanResult r) =>
+ $"{r.Status}, expanded {r.Expansions}: " + string.Join("; ", r.Findings.Select(f => f.Message).Take(5));
+}
diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md
index 549a986..2dbaaf1 100644
--- a/docs/cutting-planner.md
+++ b/docs/cutting-planner.md
@@ -74,10 +74,21 @@ the final replay enforce them:
## Search and exact output
-With regeneration, the bounded deterministic search considers whole-part order,
-internal contour order and native entry candidates together. Internal contours
-precede their own perimeter; parts remain contiguous. Backtracking can revisit
-an earlier entry when a later part cannot be reached safely.
+With regeneration, the bounded deterministic search plans internal contour order
+and native entry candidates part by part along a whole-part order. Internal
+contours precede their own perimeter; parts remain contiguous. Backtracking can
+revisit an earlier entry when a later part cannot be reached safely.
+
+A preserved order is followed as given. Otherwise the order is an open
+travelling-salesman path over part centres from the start point: nearest neighbour,
+then 2-opt reversals and Or-opt moves of one to three parts, never placing a part
+before a cutoff or nested-part prerequisite. If a part on that order cannot be
+reached without crossing parts already cut, the search learns "cut this part
+before those", backs up to just before the earliest of them and re-plans the rest
+from the tool position there; parts cut before that point are kept. An attempt
+stops backtracking after a stall of 8 x entries x contours expansions without
+getting further, so it learns instead of retrying every entry combination of the
+parts before it. When nothing new can be learned the result is a refusal.
Candidates use native closest points, vertices, midpoints and circle angles in
stable order, capped by `maxEntries`. Circle rounding, clamping, corner resolution