Files
OpenNest/OpenNest.Engine/CuttingPlanning/CuttingNestedOrder.cs
T
aj 4e92d0e8f3 fix(planning): route a cut-off from its start to its far end
A cut-off always runs away from its fixed edge: the origin side, the only
edge guaranteed to be a real sheet edge. The part order is proposed as a
traveling-salesman path over one point per placement, and a cut-off's
point was the middle of its line. The route did not know the tool must
arrive at the line's start and leaves at its far end, so the parts
before a sheet-splitting line finished level with its middle, and the
rapid down to its start crossed parts already cut.

Each placement now has an entry and an exit point; they differ only for
cut-offs (start and departure). Nearest neighbor and Or-opt go on from
each part's exit, and 2-opt costs a reversed run along its own directed
legs; with no cut-off on the plate the exits are not passed and the
symmetric route is unchanged. The part cut before a cut-off faces its
start.

Cut-off direction is not chosen here: the order reads each cut-off's start
and end from its program as given, and the joint search may still reverse a
head-locked far-edge segment.
2026-11-01 16:51:09 -05:00

105 lines
5.2 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Geometry;
namespace OpenNest.Engine.CuttingPlanning;
/// <summary>
/// Proposes a whole-part order for plates with parts nested inside other parts' cutouts. A host
/// is cut after everything inside it, so the tool reaches it from whichever inner part was cut
/// last; from the middle of a crowded cutout every straight approach crosses a finished
/// neighbor. Each host's inner parts are therefore toured as one run that ends beside the
/// host's own contours, immediately before the host. Plates without nested parts get exactly
/// <see cref="CuttingPartOrder"/>'s order. Like that order, this is only a proposal.
/// </summary>
internal static class CuttingNestedOrder
{
/// <param name="Centers">One representative point per part ordinal.</param>
/// <param name="Prerequisites">Ordinals that must come before each part, learned rules included.</param>
/// <param name="Inside">Per part ordinal, the parts nested inside its cutouts at any depth.</param>
/// <param name="HostEntry">The point on a host's contours nearest a tool position.</param>
/// <param name="Exits">Optional point each part is left from (a cut-off's far end); null when
/// every part is left from its center.</param>
internal sealed record Context(IReadOnlyList<Vector> Centers,
IReadOnlyList<IReadOnlyCollection<int>> Prerequisites, IReadOnlyList<IReadOnlyCollection<int>> Inside,
Func<int, Vector, Vector> HostEntry, CancellationToken Token, IReadOnlyList<Vector> Exits = null);
/// <param name="parts">The part ordinals to order; prerequisites outside this set count as done.</param>
/// <param name="start">The modeled tool position before the first of these parts.</param>
/// <param name="endpoint">Optional position the run should end beside.</param>
internal static int[] Plan(IReadOnlyList<int> parts, Vector start, Context context, Vector? endpoint = null)
{
context.Token.ThrowIfCancellationRequested();
var set = parts.ToHashSet();
var rootOf = new Dictionary<int, int>();
foreach (var host in parts)
foreach (var inner in context.Inside[host])
if (set.Contains(inner) && !context.Inside[inner].Contains(host))
rootOf[inner] = host;
if (rootOf.Count == 0)
return Flat();
// Follow nested hosts up to the outermost one present.
foreach (var inner in rootOf.Keys.ToArray())
while (rootOf.TryGetValue(rootOf[inner], out var outer))
rootOf[inner] = outer;
var roots = parts.Where(p => !rootOf.ContainsKey(p)).ToArray();
// A rule between parts of two groups orders the groups.
var groupRules = context.Centers.Select(_ => new HashSet<int>()).ToArray();
foreach (var part in parts)
{
var group = rootOf.GetValueOrDefault(part, part);
foreach (var prerequisite in context.Prerequisites[part])
if (set.Contains(prerequisite) && rootOf.GetValueOrDefault(prerequisite, prerequisite) != group)
groupRules[group].Add(rootOf.GetValueOrDefault(prerequisite, prerequisite));
}
// A finished host is a large obstacle and can only be left across its own outside
// edge, so the parts beside it go first. Rules that say otherwise win.
var hostsLast = groupRules.Select(rules => new HashSet<int>(rules)).ToArray();
var hosts = rootOf.Values.ToHashSet();
foreach (var host in hosts)
foreach (var root in roots)
if (!hosts.Contains(root))
hostsLast[host].Add(root);
int[] rootOrder;
try
{
rootOrder = CuttingPartOrder.Plan(roots, context.Centers, start, hostsLast, context.Token, endpoint, context.Exits);
}
catch (InvalidOperationException)
{
try
{
rootOrder = CuttingPartOrder.Plan(roots, context.Centers, start, groupRules, context.Token, endpoint,
context.Exits);
}
catch (InvalidOperationException)
{
return Flat(); // Rules that cross groups both ways cannot keep the groups whole.
}
}
var order = new List<int>(parts.Count);
var position = start;
foreach (var root in rootOrder)
{
var inner = parts.Where(p => rootOf.TryGetValue(p, out var host) && host == root).ToArray();
if (inner.Length != 0)
order.AddRange(Plan(inner, position, context, context.HostEntry(root, position)));
order.Add(root);
position = (context.Exits ?? context.Centers)[root];
}
var placed = new HashSet<int>();
foreach (var part in order)
{
if (context.Prerequisites[part].Any(p => set.Contains(p) && !placed.Contains(p)))
return Flat();
placed.Add(part);
}
return order.ToArray();
int[] Flat() => CuttingPartOrder.Plan(parts, context.Centers, start, context.Prerequisites, context.Token, endpoint,
context.Exits);
}
}