mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-06 13:02:11 -04:00
fix(cutting): order parts as a short tour and learn blocked approaches
Free-order planning was one depth-first search over parts, contours and entries. A dead end at one part backtracked through every entry combination of the part before it (about 1,450 for a square with two holes) before trying another part order, so a 4 x 4 grid of such parts ran out of its 20000 expansions (and 200000) although cutting it row by row is safe. The whole-part order is now 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. The existing search then plans contour order and entries along that order. If a part cannot be reached without crossing parts already cut, the search learns "cut it before those", backs up to just before the earliest of them, keeps the parts cut before that point and re-plans the rest from the tool position there. An attempt stops backtracking after 8 x entries x contours expansions without getting further, so it learns instead of retrying the entries of every earlier part. When nothing new can be learned the result is a refusal, as before. A preserved order is planned exactly as before. 16- and 36-part grids, in row order and shuffled, are now ready within the default budget (they were NoSolutionWithinBudget); a 144-part grid plans in about half a second.
This commit is contained in:
1 parent
bfe2e518e6
commit
b8f3d3bf0a
5 files changed
+685
-53
No files matched your search
@@ -0,0 +1,190 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class CuttingPartOrder
|
||||
{
|
||||
private const double Epsilon = 1e-9;
|
||||
|
||||
/// <summary>Upper bound on improvement passes, so a large plate cannot loop for long.</summary>
|
||||
internal const int MaxPasses = 50;
|
||||
|
||||
/// <param name="parts">The part ordinals to order; prerequisites outside this set count as done.</param>
|
||||
/// <param name="centres">One representative point per part ordinal.</param>
|
||||
/// <param name="start">The modeled tool position before the first of these parts.</param>
|
||||
/// <param name="prerequisites">Ordinals that must come before each part; must be acyclic.</param>
|
||||
internal static int[] Plan(IReadOnlyList<int> parts, IReadOnlyList<Vector> centres, Vector start,
|
||||
IReadOnlyList<IReadOnlyCollection<int>> prerequisites, CancellationToken token)
|
||||
{
|
||||
var local = new Dictionary<int, int>();
|
||||
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<int>)prerequisites[p].Where(local.ContainsKey).Select(q => local[q]).ToArray())
|
||||
.ToArray();
|
||||
return Plan(localCentres, start, localPrerequisites, token).Select(i => parts[i]).ToArray();
|
||||
}
|
||||
|
||||
/// <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>
|
||||
internal static int[] Plan(IReadOnlyList<Vector> centres, Vector start,
|
||||
IReadOnlyList<IReadOnlyCollection<int>> 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<Vector> centres, Vector start,
|
||||
IReadOnlyList<IReadOnlyCollection<int>> 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<Vector> centres, Vector start,
|
||||
IReadOnlyList<IReadOnlyCollection<int>> 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<IReadOnlyCollection<int>> 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<Vector> centres, Vector start,
|
||||
IReadOnlyList<IReadOnlyCollection<int>> 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<int> order, IReadOnlyList<IReadOnlyCollection<int>> 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;
|
||||
}
|
||||
}
|
||||
@@ -9,29 +9,177 @@ using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.CuttingPlanning;
|
||||
|
||||
/// <summary>One forward DFS over whole-part selection and emitted contour prefixes.</summary>
|
||||
/// <summary>
|
||||
/// 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 <see cref="CuttingPartOrder"/>;
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static class JointCuttingPlanSearch
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal const int StallExpansionsPerEntry = 8;
|
||||
|
||||
internal sealed record Outcome(CuttingPlanStatus Status, IReadOnlyList<FixedProgramPlacement> Order,
|
||||
IReadOnlyList<CuttingPlanFinding> Findings, int Expansions);
|
||||
|
||||
internal static Outcome Run(CuttingPlanSnapshot snapshot, CancellationToken token)
|
||||
{
|
||||
var expansions = 0;
|
||||
var rejected = new List<CuttingPlanFinding>();
|
||||
var materials = snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray();
|
||||
var stack = new Stack<Frame>();
|
||||
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<int>(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<int>[] 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<int>[] prerequisites)
|
||||
{
|
||||
var seen = new HashSet<int>();
|
||||
var pending = new Stack<int>(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<CuttingPlanFinding> Map(CuttingPlanSnapshot snapshot, IEnumerable<PostVerificationFinding> 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);
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private sealed class Walk(CuttingPlanSnapshot snapshot, CancellationToken token)
|
||||
{
|
||||
private readonly List<CuttingPlanFinding> rejected = [];
|
||||
private readonly LeadMaterialSnapshot[] materials =
|
||||
snapshot.Placements.Where(p => !p.IsCutOff).Select(p => p.Material).ToArray();
|
||||
|
||||
internal int Expansions { get; private set; }
|
||||
|
||||
internal IReadOnlyList<CuttingPlanFinding> Rejected => rejected.Distinct().ToArray();
|
||||
|
||||
internal Outcome Ready(IReadOnlyList<FixedProgramPlacement> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Follows <paramref name="sequence"/> from <paramref name="resume"/> (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.
|
||||
/// </summary>
|
||||
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<Frame>();
|
||||
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<Edge> Expand(Node node)
|
||||
private IEnumerable<Edge> 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<FixedProgramPlacement> 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);
|
||||
/// <summary>One pass along an order: how far it got, which part stopped it and what that part crossed.</summary>
|
||||
private sealed class Attempt(int[] sequence)
|
||||
{
|
||||
private long progress = -1;
|
||||
|
||||
internal static IEnumerable<CuttingPlanFinding> Map(CuttingPlanSnapshot snapshot, IEnumerable<PostVerificationFinding> findings) =>
|
||||
findings.Select(f =>
|
||||
internal IReadOnlyList<FixedProgramPlacement> Order { get; set; }
|
||||
|
||||
/// <summary>The deepest whole-part boundary reached; its Previous chain leads back to the root.</summary>
|
||||
private Node Deepest { get; set; }
|
||||
|
||||
/// <summary>The ordinal of the part the attempt could not get past, or null.</summary>
|
||||
internal int? Blocked => Deepest == null || Deepest.Order.Length >= sequence.Length ? null
|
||||
: sequence[Deepest.Order.Length];
|
||||
|
||||
/// <summary>Parts whose completed contours the blocked part's motions crossed (it can be among them).</summary>
|
||||
internal HashSet<int> 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<PostVerificationFinding> findings)
|
||||
{
|
||||
if (Blocked != ordinal)
|
||||
return;
|
||||
foreach (var finding in findings)
|
||||
if (finding.Kind == PostVerificationKind.RapidCrossing && finding.OtherPartNumber is int other)
|
||||
Crossed.Add(other - 1);
|
||||
}
|
||||
|
||||
/// <summary>The part boundary at <paramref name="depth"/> on the way to the deepest one.</summary>
|
||||
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);
|
||||
/// <summary>A search state; Previous links a whole-part boundary to the boundary before it.</summary>
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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<InvalidOperationException>(() => Plan([new(0, 0), new(1, 0)], prerequisites));
|
||||
}
|
||||
|
||||
private static int[] Plan(Vector[] centres, IReadOnlyCollection<int>[] prerequisites) =>
|
||||
CuttingPartOrder.Plan(centres, Vector.Zero, prerequisites, CancellationToken.None);
|
||||
|
||||
private static IReadOnlyCollection<int>[] NoPrerequisites(int count) =>
|
||||
Enumerable.Range(0, count).Select(_ => (IReadOnlyCollection<int>)Array.Empty<int>()).ToArray();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Whole-part order chosen by the planner itself (the part order is not preserved).</summary>
|
||||
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));
|
||||
}
|
||||
+15
-4
@@ -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
|
||||
|
||||
Reference in new issue
Block a user