Files
OpenNest/OpenNest.Engine/NestingEngines/FlatBar/BarPacker.cs
T
ajandClaude Opus 5.5 27a03b9caf feat(engine): add FlatBar nesting engine for flat bar stock
Reads each stock as a bar and cuts parts in one row along its length, packing
part lengths with a port of the CutList saw cut-list packer (first-fit
decreasing with swap optimization, exhaustive search for small jobs) plus a
best-fill subset-sum candidate. Each part is packed at its length less its
measured self-nesting gain, and a slide-left compaction pass nests shaped ends
(turning alternate mitred parts) and refills the freed length. Every slid pose
is confirmed with NestLayoutCheck.Clears.

On five synthetic flat-bar jobs every layout is valid and complete; total cost
33696 vs Rectangles 36816 and Default 34080.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 01:54:48 -04:00

405 lines
17 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using OpenNest.Engine.Jobs;
namespace OpenNest.Engine.NestingEngines.FlatBar;
/// <summary>
/// One part instance to cut from a bar. Length is its material length less its self-nesting
/// gain, plus one spacing; Gain is how far a copy slides into the one before it, which the
/// first copy of a run does not get back.
/// </summary>
internal sealed record BarItem(int Type, double Length, int Priority, double Gain);
/// <summary>One free run of a packed bar and the items assigned to it, in cutting order.</summary>
internal sealed class PackedSegment
{
public PackedSegment(double capacity) => Capacity = capacity;
public double Capacity { get; }
public List<BarItem> Items { get; } = new();
/// <summary>
/// Items plus one gain per type: runs are cut longest first, so each type forms one run
/// whose first copy has nothing of its own type to nest into.
/// </summary>
public double Used => System.Math.Round(
Items.Sum(i => i.Length) + Items.GroupBy(i => i.Type).Sum(g => g.First().Gain), 8);
public double Remaining => System.Math.Round(Capacity - Used, 8);
public bool Fits(BarItem item) =>
item.Length + (Items.Any(i => i.Type == item.Type) ? 0 : item.Gain) <= Remaining;
}
/// <summary>One packed bar: a segment per free run of the stock's length.</summary>
internal sealed class PackedBar
{
public PackedBar(IEnumerable<PackedSegment> segments) => Segments = segments.ToList();
public IReadOnlyList<PackedSegment> Segments { get; }
public IEnumerable<BarItem> Items => Segments.SelectMany(s => s.Items);
public int Count => Segments.Sum(s => s.Items.Count);
public double Utilization => Segments.Sum(s => s.Used) / Segments.Sum(s => s.Capacity);
}
/// <summary>Bars for one stock, best-utilized first, and the items that did not fit.</summary>
internal sealed record BarPlan(IReadOnlyList<PackedBar> Bars, IReadOnlyList<BarItem> Unplaced);
/// <summary>
/// One-dimensional cut-list packer, ported from CutList.Core (the saw cut-list optimizer).
/// Lengths are "inflated": every item carries one part spacing and every segment's capacity
/// gets one spacing back, so a run of items needs spacing between neighbours but none after the
/// last, exactly like a saw kerf in CutList's <c>Bin</c>.
///
/// Small jobs (at most <see cref="ExhaustiveItemLimit"/> items, one free run per bar, unlimited
/// bars) use CutList's exhaustive search for the fewest bars, then least waste. Everything else
/// uses its AdvancedFit pipeline: first-fit decreasing bar by bar, then the swap optimization
/// that replaces a shorter group in a bar with unplaced items that fill it better. DuplicateBins
/// is omitted: it only acts on leftovers after the bar cap, when no bar can be added anyway.
///
/// OpenNest additions: items are ordered by priority before length, so a lower-number tier is
/// packed first and wins scarce bars, and the swap step never trades an item for a later tier.
/// The exhaustive search counts its nodes and stops at a fixed budget, keeping the best plan found.
/// </summary>
internal static class BarPacker
{
/// <summary>Largest item count CutList solves exhaustively (about 84 ms worst case there).</summary>
public const int ExhaustiveItemLimit = 25;
/// <summary>Exhaustive search node budget; counted, so the result is deterministic.</summary>
public const long ExhaustiveNodeBudget = 2_000_000;
/// <summary>
/// How far a part may overhang the work area. Kept just inside the layout check's
/// <see cref="NestTolerances.WorkAreaSlack"/>, matching the Rectangles lane.
/// </summary>
public const double OverhangAllowance = NestTolerances.WorkAreaSlack * 0.9;
public static BarPlan Pack(
IReadOnlyList<BarItem> items, IReadOnlyList<double> capacities, int maxBars, CancellationToken token)
{
var advanced = AdvancedFit(items, capacities, maxBars, token);
if (items.Count > ExhaustiveItemLimit || capacities.Count != 1 || maxBars < items.Count
|| advanced.Unplaced.Count > 0 || advanced.Bars.Count <= 1)
return advanced;
var exhaustive = Exhaustive(items, capacities[0], advanced.Bars.Count, token);
return exhaustive != null && exhaustive.Bars.Count < advanced.Bars.Count ? exhaustive : advanced;
}
private static BarPlan AdvancedFit(
IReadOnlyList<BarItem> items, IReadOnlyList<double> capacities, int maxBars, CancellationToken token)
{
var remaining = items
.Select((item, order) => (item, order))
.OrderBy(x => x.item.Priority)
.ThenByDescending(x => x.item.Length)
.ThenBy(x => x.item.Type)
.ThenBy(x => x.order)
.Select(x => x.item)
.ToList();
var oversized = remaining.Where(i => capacities.All(c => i.Length + i.Gain > c)).ToList();
remaining.RemoveAll(oversized.Contains);
// First fit decreasing, one bar at a time (CutList FirstFitDecreasingStep).
var bars = new List<PackedBar>();
while (remaining.Count > 0 && bars.Count < maxBars)
{
token.ThrowIfCancellationRequested();
var bar = new PackedBar(capacities.Select(c => new PackedSegment(c)));
foreach (var segment in bar.Segments)
for (var i = 0; i < remaining.Count; i++)
{
if (!segment.Fits(remaining[i]))
continue;
segment.Items.Add(remaining[i]);
remaining.RemoveAt(i);
i--;
}
if (bar.Count == 0)
break;
bars.Add(bar);
}
// Swap optimization (CutList OptimizationStep), per free run.
foreach (var segment in bars.SelectMany(b => b.Segments))
while (TryImprove(segment, remaining))
token.ThrowIfCancellationRequested();
return Finish(bars, remaining.Concat(oversized).ToList());
}
/// <summary>
/// Takes out one item of a length group (never the longest group) and refills the freed
/// space greedily from the unplaced items, keeping the swap only when the run ends fuller.
/// Only items of the same or a lower-number priority may replace the removed one.
/// </summary>
private static bool TryImprove(PackedSegment segment, List<BarItem> remaining)
{
if (segment.Items.Count == 0 || remaining.Count < 2)
return false;
var groups = segment.Items
.GroupBy(i => i.Length)
.OrderByDescending(g => g.Key)
.Skip(1)
.Select(g => g.First())
.ToList();
var shortest = remaining.Min(i => i.Length);
foreach (var removed in groups)
{
var before = segment.Remaining;
segment.Items.Remove(removed);
var free = segment.Remaining;
for (var i = 0; i < remaining.Count; i++)
{
var first = remaining[i];
// Charging the trial its own gains is conservative for types already in the run.
var trial = new PackedSegment(free);
if (!trial.Fits(first) || first.Priority > removed.Priority)
continue;
trial.Items.Add(first);
for (var j = i + 1; j < remaining.Count; j++)
{
if (trial.Remaining < shortest)
break;
var next = remaining[j];
if (!trial.Fits(next) || next.Priority > removed.Priority)
continue;
trial.Items.Add(next);
}
if (trial.Remaining < before)
{
remaining.Add(removed);
segment.Items.AddRange(trial.Items);
foreach (var item in trial.Items)
remaining.Remove(item);
return true;
}
}
segment.Items.Add(removed);
}
return false;
}
/// <summary>
/// Branch-and-bound for the fewest bars (CutList ExhaustiveFitEngine): items longest first,
/// each tried in every open bar and then a new one, pruned by bar count and a volume bound,
/// with identical items kept in non-decreasing bar order. Returns null when the budget
/// ran out before any plan better than <paramref name="barsToBeat"/> was found.
/// </summary>
private static BarPlan? Exhaustive(
IReadOnlyList<BarItem> items, double capacity, int barsToBeat, CancellationToken token)
{
var sorted = items
.Select((item, order) => (item, order))
.OrderByDescending(x => x.item.Length)
.ThenBy(x => x.item.Type)
.ThenBy(x => x.order)
.Select(x => x.item)
.ToList();
var suffix = new double[sorted.Count + 1];
for (var i = sorted.Count - 1; i >= 0; i--)
suffix[i] = suffix[i + 1] + sorted[i].Length;
var current = new List<List<BarItem>>();
var used = new List<double>();
var typeCount = items.Max(i => i.Type) + 1;
var runs = new List<int[]>();
List<List<BarItem>>? best = null;
var bestCount = barsToBeat;
long nodes = 0;
var lastBar = 0;
void Search(int index)
{
if (++nodes > ExhaustiveNodeBudget)
return;
if ((nodes & 0xFFF) == 0)
token.ThrowIfCancellationRequested();
if (index == sorted.Count)
{
// Equal bar counts hold the same items, so they waste the same length.
if (current.Count < bestCount)
{
bestCount = current.Count;
best = current.Select(b => b.ToList()).ToList();
}
return;
}
if (current.Count >= bestCount)
return;
var free = used.Sum(u => capacity - u);
var overflow = suffix[index] - free;
var more = overflow > 0 ? (int)System.Math.Ceiling(overflow / capacity) : 0;
if (current.Count + more >= bestCount)
return;
var item = sorted[index];
var minBar = index > 0 && sorted[index - 1] == item ? lastBar : 0;
for (var b = minBar; b < current.Count; b++)
{
var need = item.Length + (runs[b][item.Type] == 0 ? item.Gain : 0);
if (need > System.Math.Round(capacity - used[b], 8))
continue;
current[b].Add(item);
used[b] += need;
runs[b][item.Type]++;
var previous = lastBar;
lastBar = b;
Search(index + 1);
lastBar = previous;
runs[b][item.Type]--;
used[b] -= need;
current[b].RemoveAt(current[b].Count - 1);
}
if (current.Count + 1 < bestCount && item.Length + item.Gain <= capacity)
{
current.Add(new List<BarItem> { item });
used.Add(item.Length + item.Gain);
var run = new int[typeCount];
run[item.Type] = 1;
runs.Add(run);
var previous = lastBar;
lastBar = current.Count - 1;
Search(index + 1);
lastBar = previous;
runs.RemoveAt(runs.Count - 1);
used.RemoveAt(used.Count - 1);
current.RemoveAt(current.Count - 1);
}
}
Search(0);
if (best == null)
return null;
var bars = best.Select(items =>
{
var segment = new PackedSegment(capacity);
segment.Items.AddRange(items);
return new PackedBar(new[] { segment });
}).ToList();
return Finish(bars, Array.Empty<BarItem>());
}
/// <summary>Best-fill search node budget per run; counted, so the result is deterministic.</summary>
public const long BestFillNodeBudget = 200_000;
/// <summary>
/// Fills one bar as full as possible from the demand (the sequential heuristic of
/// cutting-stock practice), run by run: a bounded subset-sum search over part types, longest
/// first, with each type's gain charged once. The most urgent priority tier is filled first
/// and later tiers only fill what it leaves. Returns null when nothing fits.
/// </summary>
public static PackedBar? BestFill(IReadOnlyList<BarItem> items, IReadOnlyList<double> capacities, CancellationToken token)
{
var pool = items
.GroupBy(i => i.Type)
.Select(g => (Item: g.First(), Count: g.Count()))
.OrderBy(t => t.Item.Priority)
.ThenByDescending(t => t.Item.Length)
.ThenBy(t => t.Item.Type)
.ToList();
var counts = pool.Select(t => t.Count).ToArray();
var tiers = pool.Select(t => t.Item.Priority).Distinct().ToList();
var bar = new PackedBar(capacities.Select(c => new PackedSegment(c)));
foreach (var segment in bar.Segments)
foreach (var tier in tiers)
{
var members = Enumerable.Range(0, pool.Count).Where(i => pool[i].Item.Priority == tier).ToList();
var chosen = FillRun(pool, counts, members, segment, token);
for (var m = 0; m < members.Count; m++)
{
var index = members[m];
for (var k = 0; k < chosen[m]; k++)
segment.Items.Add(pool[index].Item);
counts[index] -= chosen[m];
}
}
if (bar.Count == 0)
return null;
return Finish(new List<PackedBar> { bar }, Array.Empty<BarItem>()).Bars[0];
}
/// <summary>Copies of each member type that use the most of the run's remaining length.</summary>
private static int[] FillRun(List<(BarItem Item, int Count)> pool, int[] counts, List<int> members,
PackedSegment segment, CancellationToken token)
{
var free = segment.Remaining;
var n = members.Count;
// A type already in the run (from an earlier tier) has already paid its gain.
var gains = members.Select(i => segment.Items.Any(x => x.Type == pool[i].Item.Type) ? 0 : pool[i].Item.Gain).ToArray();
// The objective is packed length; gains only consume room.
var suffix = new double[n + 1];
for (var m = n - 1; m >= 0; m--)
suffix[m] = suffix[m + 1] + counts[members[m]] * pool[members[m]].Item.Length;
var current = new int[n];
var best = new int[n];
var bestPacked = 0.0;
long nodes = 0;
void Search(int m, double used, double packed)
{
if (++nodes > BestFillNodeBudget || bestPacked >= free - 1e-9)
return;
if ((nodes & 0xFFF) == 0)
token.ThrowIfCancellationRequested();
if (packed > bestPacked)
{
bestPacked = packed;
Array.Copy(current, best, n);
}
if (m == n || packed + System.Math.Min(suffix[m], free - used) <= bestPacked + 1e-9)
return;
var item = pool[members[m]].Item;
var room = System.Math.Round(free - used, 8);
var most = room < item.Length + gains[m] ? 0
: System.Math.Min(counts[members[m]], (int)System.Math.Floor((room - gains[m]) / item.Length + 1e-9));
for (var k = most; k >= 0; k--)
{
var need = k == 0 ? 0 : k * item.Length + gains[m];
if (need > room)
continue;
current[m] = k;
Search(m + 1, used + need, packed + k * item.Length);
}
current[m] = 0;
}
Search(0, 0, 0);
return best;
}
/// <summary>Sorts each run longest first (CutList SortBinItemsStep), then bars fullest first.</summary>
private static BarPlan Finish(List<PackedBar> bars, IReadOnlyList<BarItem> unplaced)
{
foreach (var segment in bars.SelectMany(b => b.Segments))
{
var ordered = segment.Items
.OrderByDescending(i => i.Length)
.ThenBy(i => i.Priority)
.ThenBy(i => i.Type)
.ToList();
segment.Items.Clear();
segment.Items.AddRange(ordered);
}
var sorted = bars
.Select((bar, order) => (bar, order))
.OrderByDescending(x => x.bar.Utilization)
.ThenBy(x => x.bar.Count)
.ThenBy(x => x.order)
.Select(x => x.bar)
.ToList();
return new BarPlan(sorted, unplaced);
}
}