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