feat(engine): add built-in Rectangles and Irregular nesting engines
Moves the two production plug-in engines into OpenNest.Engine under names that describe the jobs they suit: - Rectangles: plain and near-rectangular plates, maximal-rectangles box packing (was the RectanglesNestingEngine plug-in) - Irregular: irregular profiles, no-fit-polygon frontier packing (was the Opus55NestingEngine plug-in) Their tests and the shared engine contract/layout test kit move into OpenNest.Engine.Tests/NestingEngines. The registry maps the old plug-in names to the new engines, so saved desktop selections, scripts and API requests keep working, and a leftover plug-in DLL under an old name cannot shadow its replacement. Desktop startup passes the registry's lookup when restoring the saved Auto Nest engine.
This commit is contained in:
@@ -1,24 +1,49 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using OpenNest.Engine.NestingEngines.Irregular;
|
||||
using OpenNest.Engine.NestingEngines.Rectangles;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// Registry of whole-job <see cref="INestingEngine"/> implementations. The four production
|
||||
/// strategies are exposed through <see cref="FixedStrategyNestingEngine"/> so they compete on
|
||||
/// equal footing with model-submitted engines. Callers choose an engine explicitly from
|
||||
/// <see cref="AvailableEngines"/>; there is no process-global active selection.
|
||||
/// strategies are exposed through <see cref="FixedStrategyNestingEngine"/>. Callers choose an
|
||||
/// engine explicitly from <see cref="AvailableEngines"/>; there is no process-global active
|
||||
/// selection. Plug-ins loaded from an Engines/ folder register under their CLR type name.
|
||||
/// </summary>
|
||||
public static class NestingEngineRegistry
|
||||
{
|
||||
private static readonly List<NestingEngineInfo> engines = new();
|
||||
|
||||
/// <summary>
|
||||
/// Registry names used by earlier releases, mapped to the engine that replaced them, so saved
|
||||
/// selections and scripts keep working. Consulted only when no engine has the requested name.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> RenamedEngines = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Opus55NestingEngine"] = "Irregular",
|
||||
["RectanglesNestingEngine"] = "Rectangles",
|
||||
};
|
||||
|
||||
static NestingEngineRegistry()
|
||||
{
|
||||
Register(
|
||||
"Rectangles",
|
||||
"Plain and near-rectangular parts: maximal-rectangles box packing",
|
||||
() => new RectanglesNestingEngine()
|
||||
);
|
||||
|
||||
Register(
|
||||
"Irregular",
|
||||
"Irregular parts: no-fit-polygon frontier packing with look-ahead stock selection",
|
||||
() => new IrregularNestingEngine()
|
||||
);
|
||||
|
||||
Register(
|
||||
"StockLadder",
|
||||
"Caller-stock constrained-first fill and equivalent-demand area repacking",
|
||||
@@ -53,13 +78,33 @@ public static class NestingEngineRegistry
|
||||
public static IReadOnlyList<NestingEngineInfo> AvailableEngines => engines;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the engine registered under <paramref name="name"/> (case-insensitive). The caller's
|
||||
/// explicit choice is the whole selection mechanism; unknown names throw.
|
||||
/// Registered name for <paramref name="name"/>: an exact (case-insensitive) match, else the
|
||||
/// engine a renamed legacy name now maps to, else null. Hosts use this to restore a saved
|
||||
/// selection made under an old name.
|
||||
/// </summary>
|
||||
public static string? ResolveName(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return null;
|
||||
var trimmed = name.Trim();
|
||||
var info = engines.FirstOrDefault(e => e.Name.Equals(trimmed, StringComparison.OrdinalIgnoreCase));
|
||||
if (info != null)
|
||||
return info.Name;
|
||||
return RenamedEngines.TryGetValue(trimmed, out var renamed)
|
||||
&& engines.FirstOrDefault(e => e.Name.Equals(renamed, StringComparison.OrdinalIgnoreCase)) is { } target
|
||||
? target.Name
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the engine registered under <paramref name="name"/> (case-insensitive, renamed legacy
|
||||
/// names accepted). The caller's explicit choice is the whole selection mechanism; unknown names throw.
|
||||
/// </summary>
|
||||
public static INestingEngine Create(string name)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
var info = engines.FirstOrDefault(e => e.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
var resolved = ResolveName(name);
|
||||
var info = resolved == null ? null : engines.First(e => e.Name == resolved);
|
||||
if (info == null)
|
||||
throw new NotSupportedException(
|
||||
$"Unknown nesting engine: {name}. Available: {string.Join(", ", engines.Select(e => e.Name))}."
|
||||
@@ -75,6 +120,13 @@ public static class NestingEngineRegistry
|
||||
return;
|
||||
}
|
||||
|
||||
// A leftover plug-in under a renamed engine's old name would shadow its built-in replacement.
|
||||
if (RenamedEngines.ContainsKey(name))
|
||||
{
|
||||
Debug.WriteLine($"[NestingEngineRegistry] '{name}' skipped: replaced by built-in '{RenamedEngines[name]}'");
|
||||
return;
|
||||
}
|
||||
|
||||
engines.Add(new NestingEngineInfo(name, description, factory));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
|
||||
/// <summary>Direction the packing front sweeps across the sheet (the free strip is left behind it).</summary>
|
||||
internal enum PackAxis
|
||||
{
|
||||
/// <summary>Front moves in +X; parts settle toward low X, then low Y.</summary>
|
||||
X,
|
||||
|
||||
/// <summary>Front moves in +Y; parts settle toward low Y, then low X.</summary>
|
||||
Y,
|
||||
}
|
||||
|
||||
internal sealed record Placed(Orientation Orientation, double X, double Y)
|
||||
{
|
||||
public double Left => X + Orientation.MinX;
|
||||
public double Right => X + Orientation.MaxX;
|
||||
public double Bottom => Y + Orientation.MinY;
|
||||
public double Top => Y + Orientation.MaxY;
|
||||
}
|
||||
|
||||
internal sealed record SheetFill(NestPlateStock Stock, IReadOnlyList<Placed> Parts, double PartArea);
|
||||
|
||||
/// <summary>
|
||||
/// Fills one sheet with a frontier-advance rule over incrementally maintained free regions.
|
||||
///
|
||||
/// For every (part type, orientation) still in play the packer keeps the exact set of legal
|
||||
/// reference points: the inner-fit rectangle of the work area minus the no-fit polygons of
|
||||
/// everything already placed. Each placement subtracts one translated NFP from each region,
|
||||
/// so regions only shrink, and a region that empties is retired for the rest of the sheet.
|
||||
///
|
||||
/// Choice rule, applied over all types and orientations at once (not in a fixed order):
|
||||
/// 1. Gap fill - if any part fits without pushing the packing front forward, place the
|
||||
/// largest such part at its lowest such point.
|
||||
/// 2. Otherwise advance - place the part whose front advance per unit area^beta is smallest,
|
||||
/// i.e. the one that buys the most material coverage for the sheet length it consumes.
|
||||
/// Parts are never placed in a sequence given up front; the sheet state decides what comes next.
|
||||
/// </summary>
|
||||
internal sealed class FrontierPacker
|
||||
{
|
||||
/// <summary>Slack added around the inner-fit rectangle so zero-width fits survive Clipper;
|
||||
/// chosen points are clamped back, which moves them far less than the clearance margin.</summary>
|
||||
private const double FitSlack = 2e-4;
|
||||
|
||||
private const double Tie = 1e-6;
|
||||
|
||||
private readonly IReadOnlyList<PartType> types;
|
||||
private readonly NoFitCache nfps;
|
||||
private readonly NestPlateStock stock;
|
||||
private readonly PackAxis axis;
|
||||
private readonly double beta;
|
||||
private readonly Box work;
|
||||
private readonly WorkCounter counter;
|
||||
|
||||
public FrontierPacker(IReadOnlyList<PartType> types, NoFitCache nfps, NestPlateStock stock, PackAxis axis, double beta, WorkCounter counter)
|
||||
{
|
||||
this.counter = counter;
|
||||
this.types = types;
|
||||
this.nfps = nfps;
|
||||
this.stock = stock;
|
||||
this.axis = axis;
|
||||
this.beta = beta;
|
||||
work = stock.WorkArea;
|
||||
}
|
||||
|
||||
public SheetFill Fill(IReadOnlyList<int> remaining, CancellationToken token)
|
||||
{
|
||||
var left = remaining.ToArray();
|
||||
var states = new List<Region>();
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (left[type.Index] <= 0)
|
||||
continue;
|
||||
foreach (var o in type.Orientations)
|
||||
if (stock.Fits(o.Width, o.Height))
|
||||
states.Add(new Region(o, work));
|
||||
}
|
||||
|
||||
var placed = new List<Placed>();
|
||||
var partArea = 0.0;
|
||||
var front = axis == PackAxis.X ? work.Left : work.Bottom;
|
||||
|
||||
while (states.Count > 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var choice = Choose(states, front);
|
||||
if (choice == null)
|
||||
break;
|
||||
|
||||
var (region, point) = choice.Value;
|
||||
var part = new Placed(region.Orientation, point.x, point.y);
|
||||
placed.Add(part);
|
||||
var typeIndex = region.Orientation.TypeIndex;
|
||||
partArea += types[typeIndex].Area;
|
||||
front = System.Math.Max(front, axis == PackAxis.X ? part.Right : part.Top);
|
||||
|
||||
if (--left[typeIndex] == 0)
|
||||
states.RemoveAll(s => s.Orientation.TypeIndex == typeIndex);
|
||||
|
||||
// Each surviving region loses the positions the new part now blocks. Regions are
|
||||
// independent, so they update in parallel without affecting determinism.
|
||||
var snapshot = states.ToArray();
|
||||
counter.Add(snapshot.Length);
|
||||
Parallel.For(
|
||||
0,
|
||||
snapshot.Length,
|
||||
new ParallelOptions { CancellationToken = token },
|
||||
i => snapshot[i].Subtract(nfps.Get(part.Orientation, snapshot[i].Orientation), part.X, part.Y)
|
||||
);
|
||||
states.RemoveAll(s => s.IsEmpty);
|
||||
}
|
||||
|
||||
return new SheetFill(stock, placed, partArea);
|
||||
}
|
||||
|
||||
private (Region, PointD)? Choose(List<Region> states, double front)
|
||||
{
|
||||
Region? bestRegion = null;
|
||||
var bestPoint = default(PointD);
|
||||
var bestFills = false;
|
||||
var bestValue = double.PositiveInfinity;
|
||||
var bestSide = double.PositiveInfinity;
|
||||
var bestLead = double.PositiveInfinity;
|
||||
var bestPriority = int.MaxValue;
|
||||
|
||||
foreach (var region in states)
|
||||
{
|
||||
if (!region.TryLowest(axis, front, out var point, out var advance, out var side, out var lead))
|
||||
continue;
|
||||
var area = types[region.Orientation.TypeIndex].Area;
|
||||
var priority = types[region.Orientation.TypeIndex].Part.Priority;
|
||||
if (priority > bestPriority) continue;
|
||||
var fills = advance <= Tie;
|
||||
// Gap fill prefers bigger parts (negated area); advance prefers least advance per area.
|
||||
var value = fills ? -area : advance / System.Math.Pow(System.Math.Max(area, 1e-12), beta);
|
||||
|
||||
var better = bestRegion == null
|
||||
|| priority < bestPriority
|
||||
|| (fills && !bestFills)
|
||||
|| (
|
||||
fills == bestFills
|
||||
&& (
|
||||
value < bestValue - Tie * System.Math.Max(1, System.Math.Abs(bestValue))
|
||||
|| (
|
||||
value <= bestValue + Tie * System.Math.Max(1, System.Math.Abs(bestValue))
|
||||
&& (side < bestSide - Tie || (side <= bestSide + Tie && lead < bestLead - Tie))
|
||||
)
|
||||
)
|
||||
);
|
||||
if (!better)
|
||||
continue;
|
||||
bestRegion = region;
|
||||
bestPriority = priority;
|
||||
bestPoint = point;
|
||||
bestFills = fills;
|
||||
bestValue = value;
|
||||
bestSide = side;
|
||||
bestLead = lead;
|
||||
}
|
||||
|
||||
return bestRegion == null ? null : (bestRegion, bestPoint);
|
||||
}
|
||||
|
||||
/// <summary>Legal reference points for one orientation on this sheet.</summary>
|
||||
private sealed class Region
|
||||
{
|
||||
private readonly double minX, minY, maxX, maxY;
|
||||
private PathsD free;
|
||||
private RectD bounds;
|
||||
|
||||
public Region(Orientation orientation, Box work)
|
||||
{
|
||||
Orientation = orientation;
|
||||
minX = work.Left - orientation.MinX;
|
||||
maxX = work.Right - orientation.MaxX;
|
||||
minY = work.Bottom - orientation.MinY;
|
||||
maxY = work.Top - orientation.MaxY;
|
||||
// Guard against fits that are infeasible by less than the bounds tolerance.
|
||||
if (maxX < minX)
|
||||
maxX = minX;
|
||||
if (maxY < minY)
|
||||
maxY = minY;
|
||||
free = new PathsD
|
||||
{
|
||||
new PathD
|
||||
{
|
||||
new(minX - FitSlack, minY - FitSlack),
|
||||
new(maxX + FitSlack, minY - FitSlack),
|
||||
new(maxX + FitSlack, maxY + FitSlack),
|
||||
new(minX - FitSlack, maxY + FitSlack),
|
||||
},
|
||||
};
|
||||
bounds = Clipper.GetBounds(free);
|
||||
}
|
||||
|
||||
public Orientation Orientation { get; }
|
||||
public bool IsEmpty => free.Count == 0;
|
||||
|
||||
public void Subtract(Nfp nfp, double dx, double dy)
|
||||
{
|
||||
if (
|
||||
nfp.Bounds.right + dx < bounds.left
|
||||
|| nfp.Bounds.left + dx > bounds.right
|
||||
|| nfp.Bounds.bottom + dy < bounds.top
|
||||
|| nfp.Bounds.top + dy > bounds.bottom
|
||||
)
|
||||
return;
|
||||
var clip = Clipper.TranslatePaths(nfp.Region, dx, dy);
|
||||
free = Clipper.Difference(free, clip, FillRule.NonZero, NoFitCache.Precision);
|
||||
// Drop numerical dust; a sliver thinner than the precision grid is no real room.
|
||||
free.RemoveAll(p => p.Count < 3);
|
||||
bounds = free.Count == 0 ? default : Clipper.GetBounds(free);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best vertex of the free region: least front advance, then lowest cross-axis position,
|
||||
/// then lowest leading edge. Vertices suffice because every score is linear in position.
|
||||
/// </summary>
|
||||
public bool TryLowest(PackAxis axis, double front, out PointD point, out double advance, out double side, out double lead)
|
||||
{
|
||||
point = default;
|
||||
advance = side = lead = double.PositiveInfinity;
|
||||
var found = false;
|
||||
var o = Orientation;
|
||||
foreach (var path in free)
|
||||
foreach (var raw in path)
|
||||
{
|
||||
var x = System.Math.Clamp(raw.x, minX, maxX);
|
||||
var y = System.Math.Clamp(raw.y, minY, maxY);
|
||||
double reach, across, start;
|
||||
if (axis == PackAxis.X)
|
||||
{
|
||||
reach = x + o.MaxX;
|
||||
across = y + o.MinY;
|
||||
start = x + o.MinX;
|
||||
}
|
||||
else
|
||||
{
|
||||
reach = y + o.MaxY;
|
||||
across = x + o.MinX;
|
||||
start = y + o.MinY;
|
||||
}
|
||||
var adv = System.Math.Max(0, reach - front);
|
||||
var better = !found
|
||||
|| adv < advance - Tie
|
||||
|| (adv <= advance + Tie && (across < side - Tie || (across <= side + Tie && start < lead - Tie)));
|
||||
if (!better)
|
||||
continue;
|
||||
found = true;
|
||||
point = new PointD(x, y);
|
||||
advance = adv;
|
||||
side = across;
|
||||
lead = start;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
|
||||
/// <summary>
|
||||
/// Frontier-advance NFP packer with look-ahead stock selection.
|
||||
///
|
||||
/// Per sheet, <see cref="FrontierPacker"/> keeps the exact free region of every
|
||||
/// (part type, orientation) as inner-fit rectangle minus no-fit polygons, and repeatedly places
|
||||
/// either the largest part that fills a gap behind the packing front, or the part that advances
|
||||
/// the front least per unit of area covered. Across sheets, every available stock size is
|
||||
/// trial-packed and the one with the lowest estimated whole-job cost (its own net area plus the
|
||||
/// remaining demand at the best efficiency seen) is committed. A handful of deterministic
|
||||
/// strategy variants (front direction, area exponent) run whole-job, and the cheapest wins.
|
||||
///
|
||||
/// Fully deterministic: no clocks or randomness influence any decision.
|
||||
/// </summary>
|
||||
public sealed class IrregularNestingEngine : INestingEngine
|
||||
{
|
||||
/// <summary>Strategy variants, tried in order: (front direction, area exponent beta).</summary>
|
||||
private static readonly (PackAxis Axis, double Beta)[] Variants =
|
||||
{
|
||||
(PackAxis.X, 1.0),
|
||||
(PackAxis.Y, 1.0),
|
||||
(PackAxis.X, 0.5),
|
||||
(PackAxis.Y, 0.5),
|
||||
(PackAxis.X, 1.5),
|
||||
(PackAxis.Y, 1.5),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic work budget, in free-region subtractions, after which no further variant
|
||||
/// starts. Keeps big jobs well inside benchmark timeouts without consulting a clock.
|
||||
/// </summary>
|
||||
internal long WorkBudget { get; init; } = 1_500_000;
|
||||
|
||||
public NestJobResult Solve(
|
||||
NestJob job,
|
||||
IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
var types = PartCatalog.Build(job);
|
||||
var solver = new Solver(job, types, progress, token);
|
||||
|
||||
// Demand that no offered stock can hold in any allowed orientation is reported unplaced.
|
||||
var demand = new int[types.Count];
|
||||
foreach (var type in types)
|
||||
{
|
||||
var placeable = job.Plates.Any(stock =>
|
||||
stock.Quantity != 0
|
||||
&& type.Orientations.Any(o => stock.Fits(o.Width, o.Height))
|
||||
);
|
||||
demand[type.Index] = placeable ? type.Part.Quantity : 0;
|
||||
}
|
||||
|
||||
Plan? best = null;
|
||||
foreach (var (axis, beta) in Variants)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (best != null && solver.Work.Value >= WorkBudget)
|
||||
break;
|
||||
var plan = solver.Plan(demand, axis, beta);
|
||||
if (best == null || plan.IsBetterThan(best))
|
||||
best = plan;
|
||||
if (best.Unplaced == 0 && best.Sheets.Count == 0)
|
||||
break;
|
||||
}
|
||||
|
||||
// The last sheets hold the leftovers, which is where waste concentrates; re-plan them.
|
||||
best = solver.ImproveTail(best!, WorkBudget * 2);
|
||||
return BuildResult(job, types, best, progress);
|
||||
}
|
||||
|
||||
/// <summary>Shared state for one solve: job, catalog, NFP caches, effort meter.</summary>
|
||||
private sealed class Solver(
|
||||
NestJob job,
|
||||
IReadOnlyList<PartType> types,
|
||||
IProgress<NestJobProgress>? progress,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
private const int MaxTail = 3;
|
||||
private readonly Dictionary<double, NoFitCache> caches = new();
|
||||
|
||||
public WorkCounter Work { get; } = new();
|
||||
|
||||
private double Penalty => NestJobCost.UnplacedPartPenalty(job);
|
||||
|
||||
public Plan Plan(int[] demand, PackAxis axis, double beta)
|
||||
{
|
||||
var run = Decode(demand, axis, beta, new Dictionary<string, int>(StringComparer.Ordinal), job.Options.MaxPlates, null);
|
||||
var unplaced = types.Sum(t => t.Part.Quantity) - run.Sheets.Sum(s => s.Parts.Count);
|
||||
var reason = run.Reason;
|
||||
if (unplaced > 0 && reason == NestJobStopReason.Completed)
|
||||
reason = NestJobStopReason.NoPlacementFound; // Demand no stock can hold.
|
||||
return new Plan(run.Sheets, run.Net + unplaced * Penalty, unplaced, reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the parts off the last k sheets (k = 1..3) and re-plans just that demand with
|
||||
/// every stock forced as the first sheet, under every variant; the cheapest complete
|
||||
/// re-plan that beats the current tail replaces it. Tails are small and effort is metered.
|
||||
/// </summary>
|
||||
public Plan ImproveTail(Plan plan, long budget)
|
||||
{
|
||||
var sheets = plan.Sheets.ToList();
|
||||
for (var k = 1; k <= System.Math.Min(MaxTail, sheets.Count); k++)
|
||||
{
|
||||
if (Work.Value >= budget)
|
||||
break;
|
||||
var prefix = sheets.Take(sheets.Count - k).ToList();
|
||||
var tail = sheets.Skip(sheets.Count - k).ToList();
|
||||
var tailParts = tail.Sum(s => s.Parts.Count);
|
||||
var tailNet = tail.Sum(s => NetArea(job.Options, s));
|
||||
var tailDemand = new int[types.Count];
|
||||
foreach (var part in tail.SelectMany(s => s.Parts))
|
||||
tailDemand[part.Orientation.TypeIndex]++;
|
||||
var used = prefix
|
||||
.GroupBy(s => s.Stock.Id)
|
||||
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
|
||||
int? cap = job.Options.MaxPlates is int max ? max - prefix.Count : null;
|
||||
|
||||
Run? bestRun = null;
|
||||
var bestNet = tailNet - 1e-9 * System.Math.Max(1, tailNet);
|
||||
foreach (var (axis, beta) in Variants)
|
||||
foreach (var first in job.Plates)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var run = Decode(tailDemand, axis, beta, used, cap, first);
|
||||
if (run.Sheets.Sum(s => s.Parts.Count) != tailParts || run.Net >= bestNet)
|
||||
continue;
|
||||
bestRun = run;
|
||||
bestNet = run.Net;
|
||||
}
|
||||
|
||||
if (bestRun == null)
|
||||
continue;
|
||||
sheets = prefix.Concat(bestRun.Sheets).ToList();
|
||||
plan = plan with { Sheets = sheets.ToList(), Cost = plan.Cost - (tailNet - bestRun.Net) };
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
private NoFitCache CacheFor(NestPlateStock stock)
|
||||
{
|
||||
var clearance = System.Math.Max(0, stock.PartSpacing);
|
||||
if (!caches.TryGetValue(clearance, out var cache))
|
||||
caches[clearance] = cache = new NoFitCache(clearance);
|
||||
return cache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Greedy sheet-by-sheet decode. <paramref name="usedBefore"/> seeds finite-stock
|
||||
/// accounting, <paramref name="sheetCap"/> bounds the sheets this run may add, and
|
||||
/// <paramref name="first"/>, when set, forces the stock of the first sheet.
|
||||
/// </summary>
|
||||
private Run Decode(
|
||||
int[] demand,
|
||||
PackAxis axis,
|
||||
double beta,
|
||||
IReadOnlyDictionary<string, int> usedBefore,
|
||||
int? sheetCap,
|
||||
NestPlateStock? first
|
||||
)
|
||||
{
|
||||
var remaining = (int[])demand.Clone();
|
||||
var used = job.Plates.ToDictionary(s => s.Id, s => usedBefore.GetValueOrDefault(s.Id), StringComparer.Ordinal);
|
||||
var sheets = new List<SheetFill>();
|
||||
var net = 0.0;
|
||||
NestJobStopReason reason;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (remaining.All(r => r == 0))
|
||||
{
|
||||
reason = NestJobStopReason.Completed;
|
||||
break;
|
||||
}
|
||||
if (sheetCap is int cap && sheets.Count >= cap)
|
||||
{
|
||||
reason = NestJobStopReason.PlateLimitReached;
|
||||
break;
|
||||
}
|
||||
|
||||
var trials = new List<(SheetFill Fill, double Net)>();
|
||||
foreach (var stock in job.Plates)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (sheets.Count == 0 && first != null && !ReferenceEquals(stock, first))
|
||||
continue;
|
||||
if (stock.Quantity is int available && used[stock.Id] >= available)
|
||||
continue;
|
||||
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, sheets.Count, 0, 0));
|
||||
var packer = new FrontierPacker(types, CacheFor(stock), stock, axis, beta, Work);
|
||||
var fill = packer.Fill(remaining, token);
|
||||
if (fill.Parts.Count > 0)
|
||||
trials.Add((fill, NetArea(job.Options, fill)));
|
||||
}
|
||||
|
||||
if (trials.Count == 0)
|
||||
{
|
||||
var exhausted = job.Plates.Any(s => s.Quantity is int q && used[s.Id] >= q);
|
||||
reason = exhausted ? NestJobStopReason.StockExhausted : NestJobStopReason.NoPlacementFound;
|
||||
break;
|
||||
}
|
||||
|
||||
// Look-ahead: charge whatever a trial leaves behind at the best efficiency any trial
|
||||
// achieved, so a sheet that finishes the job competes fairly with a denser partial one.
|
||||
var remainingArea = types.Sum(t => remaining[t.Index] * t.Area);
|
||||
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Fill.PartArea, 1e-12));
|
||||
var chosen = trials
|
||||
.Select((t, order) => (t.Fill, t.Net, order, Estimate: t.Net + System.Math.Max(0, remainingArea - t.Fill.PartArea) * bestRatio))
|
||||
.OrderBy(t => t.Estimate)
|
||||
.ThenByDescending(t => t.Fill.Parts.Count)
|
||||
.ThenBy(t => t.order)
|
||||
.First();
|
||||
|
||||
sheets.Add(chosen.Fill);
|
||||
net += chosen.Net;
|
||||
used[chosen.Fill.Stock.Id]++;
|
||||
foreach (var part in chosen.Fill.Parts)
|
||||
remaining[part.Orientation.TypeIndex]--;
|
||||
}
|
||||
|
||||
return new Run(sheets, net, reason);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record Run(IReadOnlyList<SheetFill> Sheets, double Net, NestJobStopReason Reason);
|
||||
|
||||
private static NestJobResult BuildResult(NestJob job, IReadOnlyList<PartType> types,
|
||||
Plan plan, IProgress<NestJobProgress>? progress)
|
||||
{
|
||||
var builder = new NestJobResultBuilder(job, progress);
|
||||
foreach (var sheet in plan.Sheets)
|
||||
builder.AddSheet(sheet.Stock, sheet.Parts.Select(p =>
|
||||
(types[p.Orientation.TypeIndex].Part.Id, p.X, p.Y, p.Orientation.Rotation)));
|
||||
return builder.Build(plan.Reason);
|
||||
}
|
||||
|
||||
private static double NetArea(NestJobOptions options, SheetFill fill)
|
||||
{
|
||||
if (fill.Parts.Count == 0) return fill.Stock.Area;
|
||||
var left = fill.Parts.Min(p => p.Left);
|
||||
var bottom = fill.Parts.Min(p => p.Bottom);
|
||||
return NestJobCost.NetSheetArea(options, fill.Stock, new OpenNest.Geometry.Box(left, bottom,
|
||||
fill.Parts.Max(p => p.Right) - left, fill.Parts.Max(p => p.Top) - bottom));
|
||||
}
|
||||
|
||||
private sealed record Plan(IReadOnlyList<SheetFill> Sheets, double Cost, int Unplaced, NestJobStopReason Reason)
|
||||
{
|
||||
public bool IsBetterThan(Plan other)
|
||||
{
|
||||
if (Unplaced != other.Unplaced)
|
||||
return Unplaced < other.Unplaced;
|
||||
var scale = System.Math.Max(1, System.Math.Max(Cost, other.Cost));
|
||||
if (System.Math.Abs(Cost - other.Cost) > 1e-9 * scale)
|
||||
return Cost < other.Cost;
|
||||
return Sheets.Count < other.Sheets.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deterministic effort meter shared by all packers in one solve.</summary>
|
||||
internal sealed class WorkCounter
|
||||
{
|
||||
private long value;
|
||||
public long Value => Interlocked.Read(ref value);
|
||||
public void Add(long amount) => Interlocked.Add(ref value, amount);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Collections.Concurrent;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
|
||||
/// <summary>
|
||||
/// Spacing-inflated footprints and the no-fit polygons between them, for one clearance value.
|
||||
///
|
||||
/// Every placed part owns a footprint: its outline grown by half the required clearance
|
||||
/// (plus its own chord tolerance). Two parts respect the clearance exactly when their
|
||||
/// footprints do not overlap, so the whole spacing rule reduces to NFP containment.
|
||||
/// NFPs are translation-invariant, so each (orientation, orientation) pair is computed once
|
||||
/// per job and reused by every sheet, stock trial and strategy variant.
|
||||
/// </summary>
|
||||
internal sealed class NoFitCache
|
||||
{
|
||||
/// <summary>Clipper decimal precision; 1e-4 job units is far below any margin we keep.</summary>
|
||||
public const int Precision = NestTolerances.ClipperPrecision;
|
||||
|
||||
private readonly double halfClearance;
|
||||
private readonly ConcurrentDictionary<(int, int), PathD> footprints = new();
|
||||
private readonly ConcurrentDictionary<(int, int, int, int), Lazy<Nfp>> nfps = new();
|
||||
|
||||
public NoFitCache(double clearance)
|
||||
{
|
||||
halfClearance = clearance / 2;
|
||||
}
|
||||
|
||||
public PathD Footprint(Orientation o) =>
|
||||
footprints.GetOrAdd((o.TypeIndex, o.Index), _ => BuildFootprint(o));
|
||||
|
||||
/// <summary>NFP of <paramref name="moving"/> around <paramref name="fixedPart"/> placed at the origin.</summary>
|
||||
public Nfp Get(Orientation fixedPart, Orientation moving) =>
|
||||
nfps.GetOrAdd(
|
||||
(fixedPart.TypeIndex, fixedPart.Index, moving.TypeIndex, moving.Index),
|
||||
_ => new Lazy<Nfp>(() => Build(fixedPart, moving), LazyThreadSafetyMode.ExecutionAndPublication)
|
||||
)
|
||||
.Value;
|
||||
|
||||
private PathD BuildFootprint(Orientation o)
|
||||
{
|
||||
// Miter joins (squared past the limit) always contain the exact round offset, so the
|
||||
// footprint is a superset of "every point within the clearance of the outline".
|
||||
var inflated = Clipper.InflatePaths(
|
||||
new PathsD { o.Outline },
|
||||
// Four additional grid units cover this engine's repeated footprint/NFP
|
||||
// Boolean operations. Keep its established contact points and packing quality.
|
||||
halfClearance + NestTolerances.SafeClearanceMargin(o.Tolerance) / 2
|
||||
+ 4 * System.Math.Pow(10, -Precision),
|
||||
JoinType.Miter,
|
||||
EndType.Polygon,
|
||||
2.0,
|
||||
Precision,
|
||||
0.0
|
||||
);
|
||||
var best = inflated.OrderByDescending(p => System.Math.Abs(Clipper.Area(p))).First();
|
||||
if (!Clipper.IsPositive(best))
|
||||
best.Reverse();
|
||||
return best;
|
||||
}
|
||||
|
||||
private Nfp Build(Orientation fixedPart, Orientation moving)
|
||||
{
|
||||
var a = Footprint(fixedPart);
|
||||
var b = Footprint(moving);
|
||||
var negB = new PathD(b.Count);
|
||||
foreach (var p in b)
|
||||
negB.Add(new PointD(-p.x, -p.y));
|
||||
|
||||
PathsD region;
|
||||
if (IsConvex(a) && IsConvex(b))
|
||||
{
|
||||
region = new PathsD { ConvexSum(a, negB) };
|
||||
}
|
||||
else
|
||||
{
|
||||
// A (+) P, with P = -B: a reference point the boundary sweep misses puts the moving
|
||||
// copy of B clear of A's boundary, so that copy is inside A, contains A, or misses it.
|
||||
// (A + p0) covers "B inside A" and (P + a0) covers "B swallows A"; both are needed.
|
||||
var sweep = Minkowski.Sum(negB, a, true, Precision);
|
||||
sweep.Add(Clipper.TranslatePath(a, negB[0].x, negB[0].y));
|
||||
sweep.Add(Clipper.TranslatePath(negB, a[0].x, a[0].y));
|
||||
region = Clipper.Union(sweep, new PathsD(), FillRule.NonZero, Precision);
|
||||
}
|
||||
return new Nfp(region, Clipper.GetBounds(region));
|
||||
}
|
||||
|
||||
/// <summary>Minkowski sum of two convex CCW polygons by merging edges in angle order.</summary>
|
||||
private static PathD ConvexSum(PathD a, PathD b)
|
||||
{
|
||||
var ia = LowestIndex(a);
|
||||
var ib = LowestIndex(b);
|
||||
var result = new PathD(a.Count + b.Count);
|
||||
var current = new PointD(a[ia].x + b[ib].x, a[ia].y + b[ib].y);
|
||||
int i = 0, j = 0;
|
||||
while (i < a.Count || j < b.Count)
|
||||
{
|
||||
result.Add(current);
|
||||
var ea = i < a.Count ? Edge(a, ia + i) : default;
|
||||
var eb = j < b.Count ? Edge(b, ib + j) : default;
|
||||
// Both edge sequences start at the lowest vertex, so their angles rise through [0, 2pi).
|
||||
double order;
|
||||
if (i >= a.Count)
|
||||
order = -1;
|
||||
else if (j >= b.Count)
|
||||
order = 1;
|
||||
else
|
||||
{
|
||||
var difference = EdgeAngle(eb) - EdgeAngle(ea);
|
||||
order = System.Math.Abs(difference) < 1e-12 ? 0 : difference;
|
||||
}
|
||||
if (order > 0)
|
||||
{
|
||||
current = new PointD(current.x + ea.x, current.y + ea.y);
|
||||
i++;
|
||||
}
|
||||
else if (order < 0)
|
||||
{
|
||||
current = new PointD(current.x + eb.x, current.y + eb.y);
|
||||
j++;
|
||||
}
|
||||
else
|
||||
{
|
||||
current = new PointD(current.x + ea.x + eb.x, current.y + ea.y + eb.y);
|
||||
i++;
|
||||
j++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static double EdgeAngle(PointD edge)
|
||||
{
|
||||
var angle = System.Math.Atan2(edge.y, edge.x);
|
||||
return angle < 0 ? angle + System.Math.PI * 2 : angle;
|
||||
}
|
||||
|
||||
private static PointD Edge(PathD path, int index)
|
||||
{
|
||||
var from = path[index % path.Count];
|
||||
var to = path[(index + 1) % path.Count];
|
||||
return new PointD(to.x - from.x, to.y - from.y);
|
||||
}
|
||||
|
||||
/// <summary>Lowest (then leftmost) vertex: the start of a CCW edge sequence sorted by angle.</summary>
|
||||
private static int LowestIndex(PathD path)
|
||||
{
|
||||
var best = 0;
|
||||
for (var i = 1; i < path.Count; i++)
|
||||
if (path[i].y < path[best].y || (path[i].y == path[best].y && path[i].x < path[best].x))
|
||||
best = i;
|
||||
return best;
|
||||
}
|
||||
|
||||
private static bool IsConvex(PathD path)
|
||||
{
|
||||
var n = path.Count;
|
||||
if (n < 3)
|
||||
return false;
|
||||
for (var i = 0; i < n; i++)
|
||||
{
|
||||
var a = path[i];
|
||||
var b = path[(i + 1) % n];
|
||||
var c = path[(i + 2) % n];
|
||||
var cross = (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x);
|
||||
if (cross < -1e-12)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Forbidden reference-point region (interior = overlap, boundary = touching) and its bounds.</summary>
|
||||
internal sealed record Nfp(PathsD Region, RectD Bounds);
|
||||
@@ -0,0 +1,161 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Clipper2Lib;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Irregular;
|
||||
|
||||
/// <summary>
|
||||
/// One allowed pose of a part type: its rotation, its polygonized outline at that rotation
|
||||
/// (reference point = snapshot origin), and the outline's conservative bounds.
|
||||
/// </summary>
|
||||
internal sealed class Orientation
|
||||
{
|
||||
public required int TypeIndex { get; init; }
|
||||
public required int Index { get; init; }
|
||||
public required double Rotation { get; init; }
|
||||
|
||||
/// <summary>CCW outline whose every point lies within <see cref="Tolerance"/> of the true perimeter.</summary>
|
||||
public required PathD Outline { get; init; }
|
||||
|
||||
/// <summary>Chord deviation used for arcs; footprints are grown by it to stay conservative.</summary>
|
||||
public required double Tolerance { get; init; }
|
||||
|
||||
/// <summary>Outline bounds grown by the tolerance, so they contain the true perimeter.</summary>
|
||||
public required double MinX { get; init; }
|
||||
public required double MinY { get; init; }
|
||||
public required double MaxX { get; init; }
|
||||
public required double MaxY { get; init; }
|
||||
|
||||
public double Width => MaxX - MinX;
|
||||
public double Height => MaxY - MinY;
|
||||
}
|
||||
|
||||
internal sealed class PartType
|
||||
{
|
||||
public required int Index { get; init; }
|
||||
public required NestJobPart Part { get; init; }
|
||||
public required double Area { get; init; }
|
||||
public required IReadOnlyList<Orientation> Orientations { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts job snapshots into the polygon world the packer works in. Parts whose geometry
|
||||
/// cannot be read are kept with no orientations, so they surface as unplaced instead of
|
||||
/// failing the whole job.
|
||||
/// </summary>
|
||||
internal static class PartCatalog
|
||||
{
|
||||
/// <summary>Finest chord deviation of the working outline from true arcs, in job units.</summary>
|
||||
public const double ChordTolerance = 0.002;
|
||||
|
||||
/// <summary>Outline vertex count above which arcs are polygonized more coarsely (NFP cost is ~n*m).</summary>
|
||||
private const int TargetVertices = 64;
|
||||
|
||||
/// <summary>Hard cap on distinct orientations evaluated per part type.</summary>
|
||||
private const int MaxOrientations = 8;
|
||||
|
||||
public static IReadOnlyList<PartType> Build(NestJob job)
|
||||
{
|
||||
// Fewer orientations per type for jobs with many distinct parts; every (type, rotation)
|
||||
// pair costs a feasible-region update per placement.
|
||||
var perType = System.Math.Clamp(48 / System.Math.Max(1, job.Parts.Count), 2, MaxOrientations);
|
||||
var types = new List<PartType>(job.Parts.Count);
|
||||
for (var index = 0; index < job.Parts.Count; index++)
|
||||
{
|
||||
var part = job.Parts[index];
|
||||
Shape? perimeter;
|
||||
try
|
||||
{
|
||||
perimeter = ReadPerimeter(part.Geometry);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or InvalidOperationException)
|
||||
{
|
||||
perimeter = null;
|
||||
}
|
||||
|
||||
if (perimeter == null)
|
||||
{
|
||||
types.Add(new PartType { Index = index, Part = part, Area = 0, Orientations = [] });
|
||||
continue;
|
||||
}
|
||||
|
||||
var angles = RotationCandidates.DistinctOutlines(perimeter,
|
||||
CandidateAngles(part.Rotation, perimeter, perType));
|
||||
var tolerance = ChooseTolerance(perimeter);
|
||||
var orientations = new List<Orientation>();
|
||||
foreach (var angle in angles)
|
||||
{
|
||||
var outline = Polygonize(perimeter, angle, tolerance);
|
||||
if (outline.Count < 3)
|
||||
continue;
|
||||
orientations.Add(MakeOrientation(index, orientations.Count, angle, outline, tolerance));
|
||||
}
|
||||
|
||||
var area = orientations.Count == 0 ? 0 : System.Math.Abs(Clipper.Area(orientations[0].Outline));
|
||||
types.Add(new PartType { Index = index, Part = part, Area = area, Orientations = orientations });
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
private static Shape? ReadPerimeter(PartGeometrySnapshot geometry) =>
|
||||
JobPartGeometry.TryRead(geometry)?.Perimeter;
|
||||
|
||||
/// <summary>
|
||||
/// Coarsens arc polygonization (up to 0.1% of the part size) until the outline is small
|
||||
/// enough for cheap Minkowski sums. Lines are always exact, so only arc-heavy parts pay.
|
||||
/// </summary>
|
||||
private static double ChooseTolerance(Shape perimeter)
|
||||
{
|
||||
var box = perimeter.BoundingBox;
|
||||
var cap = System.Math.Max(ChordTolerance, 0.001 * System.Math.Max(box.Width, box.Length));
|
||||
var tolerance = ChordTolerance;
|
||||
while (tolerance * 2 <= cap && perimeter.ToPolygonWithTolerance(tolerance).Vertices.Count > TargetVertices)
|
||||
tolerance *= 2;
|
||||
return tolerance;
|
||||
}
|
||||
|
||||
private static PathD Polygonize(Shape perimeter, double angle, double tolerance)
|
||||
{
|
||||
var shape = (Shape)perimeter.Clone();
|
||||
if (angle != 0)
|
||||
shape.Rotate(angle);
|
||||
var polygon = shape.ToPolygonWithTolerance(tolerance);
|
||||
var path = new PathD(polygon.Vertices.Count);
|
||||
foreach (var v in polygon.Vertices)
|
||||
{
|
||||
if (path.Count > 0 && System.Math.Abs(path[^1].x - v.X) < 1e-9 && System.Math.Abs(path[^1].y - v.Y) < 1e-9)
|
||||
continue;
|
||||
path.Add(new PointD(v.X, v.Y));
|
||||
}
|
||||
if (path.Count > 1 && System.Math.Abs(path[0].x - path[^1].x) < 1e-9 && System.Math.Abs(path[0].y - path[^1].y) < 1e-9)
|
||||
path.RemoveAt(path.Count - 1);
|
||||
if (!Clipper.IsPositive(path))
|
||||
path.Reverse();
|
||||
return path;
|
||||
}
|
||||
|
||||
private static Orientation MakeOrientation(int typeIndex, int index, double angle, PathD outline, double tolerance)
|
||||
{
|
||||
var bounds = Clipper.GetBounds(outline);
|
||||
return new Orientation
|
||||
{
|
||||
TypeIndex = typeIndex,
|
||||
Index = index,
|
||||
Rotation = angle,
|
||||
Outline = outline,
|
||||
Tolerance = tolerance,
|
||||
MinX = bounds.left - tolerance,
|
||||
MinY = bounds.top - tolerance, // Clipper RectD: top is the minimum Y.
|
||||
MaxX = bounds.right + tolerance,
|
||||
MaxY = bounds.bottom + tolerance,
|
||||
};
|
||||
}
|
||||
|
||||
internal static List<double> CandidateAngles(RotationPolicy policy, Shape perimeter, int limit) =>
|
||||
RotationCandidates.ForShape(policy, perimeter, limit).ToList();
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Converters;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Rectangles;
|
||||
|
||||
/// <summary>One allowed rotation of a part, reduced to its analytic material bounding box.</summary>
|
||||
/// <param name="Angle">Rotation in radians about the snapshot origin.</param>
|
||||
/// <param name="Width">Material X extent after rotation.</param>
|
||||
/// <param name="Height">Material Y extent after rotation.</param>
|
||||
/// <param name="OffsetX">Rotated material bounds' left edge relative to the snapshot origin.</param>
|
||||
/// <param name="OffsetY">Rotated material bounds' bottom edge relative to the snapshot origin.</param>
|
||||
internal sealed record BoxOrientation(double Angle, double Width, double Height, double OffsetX, double OffsetY);
|
||||
|
||||
/// <summary>A requested part type: every instance shares the same orientations.</summary>
|
||||
internal sealed record BoxType(
|
||||
int Index,
|
||||
NestJobPart Part,
|
||||
IReadOnlyList<BoxOrientation> Orientations,
|
||||
double MaterialArea)
|
||||
{
|
||||
public string Id => Part.Id;
|
||||
public int Priority => Part.Priority;
|
||||
|
||||
/// <summary>Smallest bounding-box area over the allowed orientations.</summary>
|
||||
public double BoxArea => Orientations.Count == 0 ? 0 : Orientations.Min(o => o.Width * o.Height);
|
||||
|
||||
/// <summary>Shortest side over all orientations; free space narrower than this is useless.</summary>
|
||||
public double MinSide => Orientations.Count == 0 ? double.MaxValue
|
||||
: Orientations.Min(o => System.Math.Min(o.Width, o.Height));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reduces every requested part to the axis-aligned boxes of its useful rotations. Only material
|
||||
/// contours count (rapids and scribe/etch marks are excluded), exactly as the layout check's
|
||||
/// bounds test does. Orientations are the host rotation candidates whose box area is within a
|
||||
/// hair of the minimum (the minimum-area bounding rectangle plus its right-angle turn), with
|
||||
/// duplicate box shapes removed. Unreadable geometry yields a type with no orientations.
|
||||
/// </summary>
|
||||
internal static class BoxCatalog
|
||||
{
|
||||
private const double AreaTieRelative = 1e-6;
|
||||
private const double DimensionTie = 1e-7;
|
||||
|
||||
public static IReadOnlyList<BoxType> Build(NestJob job)
|
||||
{
|
||||
var types = new List<BoxType>(job.Parts.Count);
|
||||
for (var i = 0; i < job.Parts.Count; i++)
|
||||
types.Add(Read(i, job.Parts[i]));
|
||||
return types;
|
||||
}
|
||||
|
||||
private static BoxType Read(int index, NestJobPart part)
|
||||
{
|
||||
var geometry = JobPartGeometry.TryRead(part.Geometry);
|
||||
if (geometry == null)
|
||||
return new BoxType(index, part, Array.Empty<BoxOrientation>(), 0);
|
||||
|
||||
var candidates = new List<BoxOrientation>();
|
||||
foreach (var angle in RotationCandidates.ForShape(part.Rotation, geometry.Perimeter))
|
||||
{
|
||||
var bounds = RotatedMaterialBounds(part.Geometry, angle);
|
||||
if (bounds is not { } b || !(b.Width > 0) || !(b.Height > 0))
|
||||
continue;
|
||||
candidates.Add(new BoxOrientation(angle, b.Width, b.Height, b.Left, b.Bottom));
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return new BoxType(index, part, Array.Empty<BoxOrientation>(), geometry.MaterialArea);
|
||||
|
||||
var minArea = candidates.Min(c => c.Width * c.Height);
|
||||
var kept = new List<BoxOrientation>();
|
||||
foreach (var c in candidates)
|
||||
{
|
||||
if (c.Width * c.Height > minArea * (1 + AreaTieRelative))
|
||||
continue;
|
||||
if (kept.Any(k => System.Math.Abs(k.Width - c.Width) <= DimensionTie
|
||||
&& System.Math.Abs(k.Height - c.Height) <= DimensionTie))
|
||||
continue;
|
||||
kept.Add(c);
|
||||
}
|
||||
return new BoxType(index, part, kept, geometry.MaterialArea);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Material bounds after rotation, as the layout check will see them. The check flattens
|
||||
/// perimeter arcs circumscribed and snaps to a 1e-4 Clipper grid, so a curved extreme reads
|
||||
/// slightly outside the true arc. Each side takes the larger of the analytic bound and the
|
||||
/// check's own outline (ClipperBridge.OffsetForValidation at zero inflation, flattened in the
|
||||
/// same local frame), and any side where the outline sticks out gets one more grid unit.
|
||||
/// Straight edges are unchanged, so rectangles still pack at exactly the part spacing.
|
||||
/// </summary>
|
||||
private static (double Left, double Bottom, double Width, double Height)? RotatedMaterialBounds(
|
||||
PartGeometrySnapshot snapshot, double angle)
|
||||
{
|
||||
var entities = ConvertProgram.ToGeometry(DrawingJobMapper.ToProgram(snapshot))
|
||||
.Where(e => SpecialLayers.IsMaterial(e.Layer))
|
||||
.ToList();
|
||||
if (entities.Count == 0)
|
||||
return null;
|
||||
foreach (var entity in entities)
|
||||
entity.Rotate(angle);
|
||||
var left = entities.Min(e => e.Left);
|
||||
var bottom = entities.Min(e => e.Bottom);
|
||||
var right = entities.Max(e => e.Right);
|
||||
var top = entities.Max(e => e.Top);
|
||||
if (!double.IsFinite(left) || !double.IsFinite(bottom) || !double.IsFinite(right) || !double.IsFinite(top))
|
||||
return null;
|
||||
|
||||
var profile = new ShapeProfile(entities);
|
||||
var outline = profile.Perimeter == null ? null
|
||||
: ClipperBridge.OffsetForValidation(profile, 0, NestTolerances.ValidationOutline).LargestOuter();
|
||||
if (outline != null && outline.Vertices.Count >= 3)
|
||||
{
|
||||
left = Widen(left, outline.Vertices.Min(v => v.X), -1);
|
||||
bottom = Widen(bottom, outline.Vertices.Min(v => v.Y), -1);
|
||||
right = Widen(right, outline.Vertices.Max(v => v.X), +1);
|
||||
top = Widen(top, outline.Vertices.Max(v => v.Y), +1);
|
||||
}
|
||||
return (left, bottom, right - left, top - bottom);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes a side out to the check's outline plus one grid unit when the outline sticks out by
|
||||
/// more than a quarter of the spacing slack. Smaller differences are grid rounding (at most half
|
||||
/// a unit per vertex) or negligible bulge: two facing sides then lose under 0.00035 in total,
|
||||
/// inside NestTolerances.SpacingSlack, and ignoring them keeps rotated rectangles exact.
|
||||
/// </summary>
|
||||
private static double Widen(double analytic, double outline, int direction)
|
||||
{
|
||||
var grid = System.Math.Pow(10, -NestTolerances.ClipperPrecision);
|
||||
var beyond = (outline - analytic) * direction;
|
||||
return beyond > NestTolerances.SpacingSlack / 4 ? outline + direction * grid : analytic;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
namespace OpenNest.Engine.NestingEngines.Rectangles;
|
||||
|
||||
/// <summary>Axis-aligned rectangle in sheet-local packing coordinates.</summary>
|
||||
internal readonly record struct Rect(double X, double Y, double W, double H)
|
||||
{
|
||||
public double Right => X + W;
|
||||
public double Top => Y + H;
|
||||
|
||||
public bool Contains(Rect other) =>
|
||||
other.X >= X - MaxRectsSheet.Eps && other.Y >= Y - MaxRectsSheet.Eps
|
||||
&& other.Right <= Right + MaxRectsSheet.Eps && other.Top <= Top + MaxRectsSheet.Eps;
|
||||
|
||||
public bool Overlaps(Rect other) =>
|
||||
other.X < Right - MaxRectsSheet.Eps && other.Right > X + MaxRectsSheet.Eps
|
||||
&& other.Y < Top - MaxRectsSheet.Eps && other.Top > Y + MaxRectsSheet.Eps;
|
||||
}
|
||||
|
||||
/// <summary>How a free position is scored; lower (Primary, Secondary) wins.</summary>
|
||||
internal enum FitRule
|
||||
{
|
||||
/// <summary>Smallest leftover on the tighter side of the free rectangle.</summary>
|
||||
BestShortSide,
|
||||
/// <summary>Smallest leftover on the looser side of the free rectangle.</summary>
|
||||
BestLongSide,
|
||||
/// <summary>Smallest free rectangle that holds the item.</summary>
|
||||
BestArea,
|
||||
/// <summary>Lowest top edge, then leftmost: packs rows upward and keeps a clean top offcut.</summary>
|
||||
BottomLeft,
|
||||
/// <summary>Leftmost right edge, then lowest: packs columns rightward and keeps a clean right offcut.</summary>
|
||||
LeftBottom,
|
||||
/// <summary>Most perimeter touching the sheet edge or already placed items.</summary>
|
||||
ContactPoint,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maximal-rectangles free-space tracker for one sheet (Jylänki, "A Thousand Ways to Pack the
|
||||
/// Bin", 2010). Keeps every maximal empty rectangle, so any position a box can legally occupy
|
||||
/// is the bottom-left corner of some free rectangle. Items and the bin are inflated by the part
|
||||
/// spacing on their right/top sides by the caller, so touching inflated boxes are exactly one
|
||||
/// spacing apart and the last box may touch the sheet's work-area edge.
|
||||
/// </summary>
|
||||
internal sealed class MaxRectsSheet
|
||||
{
|
||||
public const double Eps = 1e-9;
|
||||
|
||||
private readonly List<Rect> free = new();
|
||||
private readonly List<Rect> used = new();
|
||||
|
||||
public MaxRectsSheet(double width, double height)
|
||||
{
|
||||
Width = width;
|
||||
Height = height;
|
||||
free.Add(new Rect(0, 0, width, height));
|
||||
}
|
||||
|
||||
public double Width { get; }
|
||||
public double Height { get; }
|
||||
public IReadOnlyList<Rect> Used => used;
|
||||
|
||||
/// <summary>Best position for a w-by-h item under the rule, or null when nothing holds it.</summary>
|
||||
public (Rect Place, double Primary, double Secondary)? FindBest(double w, double h, FitRule rule)
|
||||
{
|
||||
(Rect Place, double Primary, double Secondary)? best = null;
|
||||
foreach (var f in free)
|
||||
{
|
||||
if (w > f.W + Eps || h > f.H + Eps)
|
||||
continue;
|
||||
var place = new Rect(f.X, f.Y, w, h);
|
||||
var (p, s) = Score(f, place, rule);
|
||||
if (best is not { } b || p < b.Primary - Eps
|
||||
|| (p <= b.Primary + Eps && s < b.Secondary - Eps))
|
||||
best = (place, p, s);
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>Commits an item and splits every free rectangle it intersects.</summary>
|
||||
public void Place(Rect item)
|
||||
{
|
||||
var next = new List<Rect>(free.Count + 8);
|
||||
foreach (var f in free)
|
||||
{
|
||||
if (!f.Overlaps(item))
|
||||
{
|
||||
next.Add(f);
|
||||
continue;
|
||||
}
|
||||
if (item.X > f.X + Eps)
|
||||
next.Add(new Rect(f.X, f.Y, item.X - f.X, f.H));
|
||||
if (item.Right < f.Right - Eps)
|
||||
next.Add(new Rect(item.Right, f.Y, f.Right - item.Right, f.H));
|
||||
if (item.Y > f.Y + Eps)
|
||||
next.Add(new Rect(f.X, f.Y, f.W, item.Y - f.Y));
|
||||
if (item.Top < f.Top - Eps)
|
||||
next.Add(new Rect(f.X, item.Top, f.W, f.Top - item.Top));
|
||||
}
|
||||
free.Clear();
|
||||
free.AddRange(Prune(next));
|
||||
used.Add(item);
|
||||
}
|
||||
|
||||
private static List<Rect> Prune(List<Rect> rects)
|
||||
{
|
||||
// Drop rectangles contained in another; of two equal ones keep the first (deterministic).
|
||||
var keep = new bool[rects.Count];
|
||||
for (var i = 0; i < rects.Count; i++)
|
||||
keep[i] = rects[i].W > Eps && rects[i].H > Eps;
|
||||
for (var i = 0; i < rects.Count; i++)
|
||||
{
|
||||
if (!keep[i])
|
||||
continue;
|
||||
for (var j = 0; j < rects.Count; j++)
|
||||
{
|
||||
if (i == j || !keep[j])
|
||||
continue;
|
||||
if (rects[j].Contains(rects[i]) && (!rects[i].Contains(rects[j]) || j < i))
|
||||
{
|
||||
keep[i] = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
var result = new List<Rect>(rects.Count);
|
||||
for (var i = 0; i < rects.Count; i++)
|
||||
if (keep[i])
|
||||
result.Add(rects[i]);
|
||||
return result;
|
||||
}
|
||||
|
||||
private (double Primary, double Secondary) Score(Rect f, Rect place, FitRule rule)
|
||||
{
|
||||
var dx = f.W - place.W;
|
||||
var dy = f.H - place.H;
|
||||
return rule switch
|
||||
{
|
||||
FitRule.BestShortSide => (System.Math.Min(dx, dy), System.Math.Max(dx, dy)),
|
||||
FitRule.BestLongSide => (System.Math.Max(dx, dy), System.Math.Min(dx, dy)),
|
||||
FitRule.BestArea => (f.W * f.H - place.W * place.H, System.Math.Min(dx, dy)),
|
||||
FitRule.BottomLeft => (place.Top, place.X),
|
||||
FitRule.LeftBottom => (place.Right, place.Y),
|
||||
FitRule.ContactPoint => (-Contact(place), place.Top + place.Right),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(rule)),
|
||||
};
|
||||
}
|
||||
|
||||
private double Contact(Rect r)
|
||||
{
|
||||
var total = 0.0;
|
||||
if (r.X <= Eps) total += r.H;
|
||||
if (r.Right >= Width - Eps) total += r.H;
|
||||
if (r.Y <= Eps) total += r.W;
|
||||
if (r.Top >= Height - Eps) total += r.W;
|
||||
foreach (var u in used)
|
||||
{
|
||||
if (System.Math.Abs(u.X - r.Right) <= Eps || System.Math.Abs(u.Right - r.X) <= Eps)
|
||||
total += Overlap(u.Y, u.Top, r.Y, r.Top);
|
||||
if (System.Math.Abs(u.Y - r.Top) <= Eps || System.Math.Abs(u.Top - r.Y) <= Eps)
|
||||
total += Overlap(u.X, u.Right, r.X, r.Right);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
private static double Overlap(double a0, double a1, double b0, double b1) =>
|
||||
System.Math.Max(0, System.Math.Min(a1, b1) - System.Math.Max(a0, b0));
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Rectangles;
|
||||
|
||||
/// <summary>
|
||||
/// Rectangle-lane nesting engine: every part is nested as the axis-aligned box of its material at
|
||||
/// its minimum-area rotations, packed with a maximal-rectangles free list (Jylänki 2010).
|
||||
///
|
||||
/// Built for jobs of plain and near-rectangular parts, where a part's box wastes almost nothing
|
||||
/// and exact box packing beats contour-sliding engines on both speed and density. Irregular parts
|
||||
/// are still placed validly, only as their bounding boxes; they are not nested into each other.
|
||||
///
|
||||
/// Sheet by sheet, each available stock is packed under several free-space scoring rules and
|
||||
/// two pick modes (best-fitting box anywhere, or largest type first). The candidate sheet with
|
||||
/// the lowest estimated whole-job cost wins: its salvage-credited net area (NestJobCost) plus the
|
||||
/// remaining demand priced at the best net-area-per-part-area ratio seen among the candidates.
|
||||
/// Deterministic: no clocks or randomness; the only stop besides completion is the host token.
|
||||
/// </summary>
|
||||
public sealed class RectanglesNestingEngine : INestingEngine
|
||||
{
|
||||
private static readonly FitRule[] Rules =
|
||||
{
|
||||
FitRule.BestShortSide, FitRule.BestLongSide, FitRule.BestArea,
|
||||
FitRule.BottomLeft, FitRule.LeftBottom, FitRule.ContactPoint,
|
||||
};
|
||||
|
||||
private static readonly PickMode[] Modes = { PickMode.Global, PickMode.Ordered };
|
||||
|
||||
public NestJobResult Solve(
|
||||
NestJob job,
|
||||
IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var types = BoxCatalog.Build(job);
|
||||
var remaining = types.Select(t => t.Part.Quantity).ToArray();
|
||||
// Parts with unreadable geometry or no box that fits any offered sheet can never be placed.
|
||||
foreach (var t in types)
|
||||
if (t.Orientations.Count == 0 || !job.Plates.Any(stock => t.Orientations.Any(o => FitsStock(stock, o))))
|
||||
remaining[t.Index] = 0;
|
||||
|
||||
var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
|
||||
var result = new NestJobResultBuilder(job, progress);
|
||||
NestJobStopReason reason;
|
||||
|
||||
while (true)
|
||||
{
|
||||
if (remaining.All(r => r == 0))
|
||||
{
|
||||
reason = NestJobStopReason.NoPlacementFound; // Builder reports Completed when demand is met.
|
||||
break;
|
||||
}
|
||||
if (job.Options.MaxPlates is int cap && result.SheetsUsed(job) >= cap)
|
||||
{
|
||||
reason = NestJobStopReason.PlateLimitReached;
|
||||
break;
|
||||
}
|
||||
|
||||
var trials = new List<(SheetPlan Plan, double Net)>();
|
||||
foreach (var stock in job.Plates)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (stock.Quantity is int available && used[stock.Id] >= available)
|
||||
continue;
|
||||
progress?.Report(new NestJobProgress(
|
||||
NestJobStage.EvaluatingCandidate, stock.Id, result.SheetsUsed(job), result.SheetsUsed(job), 0));
|
||||
foreach (var mode in Modes)
|
||||
foreach (var rule in Rules)
|
||||
{
|
||||
var plan = SheetPacker.Pack(types, remaining, stock, rule, mode, token);
|
||||
if (plan.Parts.Count > 0)
|
||||
trials.Add((plan, NetArea(job, plan)));
|
||||
}
|
||||
}
|
||||
|
||||
if (trials.Count == 0)
|
||||
{
|
||||
var exhausted = job.Plates.Any(s => s.Quantity is int q && used[s.Id] >= q);
|
||||
reason = exhausted ? NestJobStopReason.StockExhausted : NestJobStopReason.NoPlacementFound;
|
||||
break;
|
||||
}
|
||||
|
||||
var chosen = Choose(types, remaining, trials);
|
||||
result.AddSheet(chosen.Stock, chosen.Poses());
|
||||
used[chosen.Stock.Id]++;
|
||||
foreach (var p in chosen.Parts)
|
||||
remaining[p.Type.Index]--;
|
||||
}
|
||||
|
||||
return result.Build(reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the sheet with the lowest estimated whole-job cost. Remaining demand is priced at the
|
||||
/// best net-area-per-material ratio any candidate achieved, so a sheet that finishes the job
|
||||
/// competes fairly with a denser partial one. Ties: more material placed, then enumeration order.
|
||||
/// </summary>
|
||||
private static SheetPlan Choose(
|
||||
IReadOnlyList<BoxType> types, int[] remaining, List<(SheetPlan Plan, double Net)> trials)
|
||||
{
|
||||
var demandArea = types.Sum(t => remaining[t.Index] * t.MaterialArea);
|
||||
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Plan.MaterialArea, 1e-12));
|
||||
return trials
|
||||
.Select((t, order) => (t.Plan, order,
|
||||
Estimate: t.Net + System.Math.Max(0, demandArea - t.Plan.MaterialArea) * bestRatio))
|
||||
.OrderBy(t => PriorityDebt(types, remaining, t.Plan))
|
||||
.ThenBy(t => t.Estimate)
|
||||
.ThenByDescending(t => t.Plan.MaterialArea)
|
||||
.ThenBy(t => t.order)
|
||||
.First()
|
||||
.Plan;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Priority guard: how many instances of the most urgent (lowest-number) tier with remaining
|
||||
/// demand this plan leaves unplaced. Plans are ranked on this before cost, so a cheaper sheet
|
||||
/// can never win by serving a later tier at the expense of an earlier one.
|
||||
/// </summary>
|
||||
private static int PriorityDebt(IReadOnlyList<BoxType> types, int[] remaining, SheetPlan plan)
|
||||
{
|
||||
var active = types.Where(t => remaining[t.Index] > 0).ToList();
|
||||
if (active.Count == 0)
|
||||
return 0;
|
||||
var top = active.Min(t => t.Priority);
|
||||
var placed = plan.Parts.Count(p => p.Type.Priority == top);
|
||||
return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed;
|
||||
}
|
||||
|
||||
private static double NetArea(NestJob job, SheetPlan plan) =>
|
||||
plan.Envelope is { } envelope
|
||||
? NestJobCost.NetSheetArea(job.Options, plan.Stock, envelope)
|
||||
: plan.Stock.Area;
|
||||
|
||||
private static bool FitsStock(NestPlateStock stock, BoxOrientation o)
|
||||
{
|
||||
var work = stock.WorkArea;
|
||||
return o.Width <= work.Right - work.Left + MaxRectsSheet.Eps
|
||||
&& o.Height <= work.Top - work.Bottom + MaxRectsSheet.Eps;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ResultBuilderExtensions
|
||||
{
|
||||
public static int SheetsUsed(this NestJobResultBuilder builder, NestJob job) =>
|
||||
job.Plates.Sum(builder.SheetsUsed);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.NestingEngines.Rectangles;
|
||||
|
||||
/// <summary>How the next box is chosen on a sheet.</summary>
|
||||
internal enum PickMode
|
||||
{
|
||||
/// <summary>Every step places whichever remaining type/orientation scores best anywhere.</summary>
|
||||
Global,
|
||||
/// <summary>Types in (priority, largest box first) order; each fills until it no longer fits.</summary>
|
||||
Ordered,
|
||||
}
|
||||
|
||||
/// <summary>One placed box: which part type, which orientation, and its material bounds' corner.</summary>
|
||||
internal readonly record struct Placed(BoxType Type, BoxOrientation Orientation, double Left, double Bottom);
|
||||
|
||||
/// <summary>A proposed single-sheet layout.</summary>
|
||||
internal sealed record SheetPlan(
|
||||
NestPlateStock Stock,
|
||||
IReadOnlyList<Placed> Parts,
|
||||
double MaterialArea,
|
||||
Box? Envelope,
|
||||
FitRule Rule,
|
||||
PickMode Mode)
|
||||
{
|
||||
/// <summary>Converts box corners into job poses (rotate about the snapshot origin, then translate).</summary>
|
||||
public IEnumerable<(string PartId, double X, double Y, double Rotation)> Poses() =>
|
||||
Parts.Select(p => (p.Type.Id, p.Left - p.Orientation.OffsetX, p.Bottom - p.Orientation.OffsetY,
|
||||
p.Orientation.Angle));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Packs the remaining demand onto one sheet of the given stock with a maximal-rectangles free
|
||||
/// list. Lower priority numbers are always served first: a higher-number type is only placed
|
||||
/// when no lower-number type still fits anywhere.
|
||||
/// </summary>
|
||||
internal static class SheetPacker
|
||||
{
|
||||
public static SheetPlan Pack(
|
||||
IReadOnlyList<BoxType> types, IReadOnlyList<int> remaining, NestPlateStock stock,
|
||||
FitRule rule, PickMode mode, CancellationToken token)
|
||||
{
|
||||
var work = stock.WorkArea;
|
||||
var s = stock.PartSpacing;
|
||||
var sheet = new MaxRectsSheet(work.Right - work.Left + s, work.Top - work.Bottom + s);
|
||||
var left = remaining.ToArray();
|
||||
var placed = new List<Placed>();
|
||||
|
||||
if (mode == PickMode.Global)
|
||||
PackGlobal(types, left, sheet, s, rule, placed, token);
|
||||
else
|
||||
PackOrdered(types, left, sheet, s, rule, placed, token);
|
||||
|
||||
var area = 0.0;
|
||||
Box? envelope = null;
|
||||
foreach (var p in placed)
|
||||
{
|
||||
area += p.Type.MaterialArea;
|
||||
var box = new Box(work.Left + p.Left, work.Bottom + p.Bottom, p.Orientation.Width, p.Orientation.Height);
|
||||
envelope = envelope == null ? box : Union(envelope, box);
|
||||
}
|
||||
|
||||
var world = placed
|
||||
.Select(p => p with { Left = work.Left + p.Left, Bottom = work.Bottom + p.Bottom })
|
||||
.ToList();
|
||||
return new SheetPlan(stock, world, area, envelope, rule, mode);
|
||||
}
|
||||
|
||||
private static void PackGlobal(
|
||||
IReadOnlyList<BoxType> types, int[] left, MaxRectsSheet sheet, double s, FitRule rule,
|
||||
List<Placed> placed, CancellationToken token)
|
||||
{
|
||||
// Free space only shrinks, so an orientation that fails once never fits again on this sheet.
|
||||
var dead = types.Select(t => new bool[t.Orientations.Count]).ToArray();
|
||||
var tiers = types.Select(t => t.Priority).Distinct().Order().ToArray();
|
||||
|
||||
while (true)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
(BoxType Type, int Orientation, Rect Place, double P, double S)? best = null;
|
||||
foreach (var tier in tiers)
|
||||
{
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (type.Priority != tier || left[type.Index] == 0)
|
||||
continue;
|
||||
for (var o = 0; o < type.Orientations.Count; o++)
|
||||
{
|
||||
if (dead[type.Index][o])
|
||||
continue;
|
||||
var orientation = type.Orientations[o];
|
||||
var fit = sheet.FindBest(orientation.Width + s, orientation.Height + s, rule);
|
||||
if (fit is not { } f)
|
||||
{
|
||||
dead[type.Index][o] = true;
|
||||
continue;
|
||||
}
|
||||
if (best is not { } b || Better(f.Primary, f.Secondary, type.BoxArea, b.P, b.S, b.Type.BoxArea))
|
||||
best = (type, o, f.Place, f.Primary, f.Secondary);
|
||||
}
|
||||
}
|
||||
if (best != null)
|
||||
break;
|
||||
}
|
||||
|
||||
if (best is not { } chosen)
|
||||
return;
|
||||
sheet.Place(chosen.Place);
|
||||
left[chosen.Type.Index]--;
|
||||
placed.Add(new Placed(chosen.Type, chosen.Type.Orientations[chosen.Orientation], chosen.Place.X, chosen.Place.Y));
|
||||
}
|
||||
}
|
||||
|
||||
private static void PackOrdered(
|
||||
IReadOnlyList<BoxType> types, int[] left, MaxRectsSheet sheet, double s, FitRule rule,
|
||||
List<Placed> placed, CancellationToken token)
|
||||
{
|
||||
var order = types
|
||||
.Where(t => t.Orientations.Count > 0)
|
||||
.OrderBy(t => t.Priority)
|
||||
.ThenByDescending(t => t.BoxArea)
|
||||
.ThenBy(t => t.Index);
|
||||
|
||||
foreach (var type in order)
|
||||
{
|
||||
while (left[type.Index] > 0)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
(int Orientation, Rect Place, double P, double S)? best = null;
|
||||
for (var o = 0; o < type.Orientations.Count; o++)
|
||||
{
|
||||
var orientation = type.Orientations[o];
|
||||
var fit = sheet.FindBest(orientation.Width + s, orientation.Height + s, rule);
|
||||
if (fit is { } f && (best is not { } b || Better(f.Primary, f.Secondary, 0, b.P, b.S, 0)))
|
||||
best = (o, f.Place, f.Primary, f.Secondary);
|
||||
}
|
||||
if (best is not { } chosen)
|
||||
break;
|
||||
sheet.Place(chosen.Place);
|
||||
left[type.Index]--;
|
||||
placed.Add(new Placed(type, type.Orientations[chosen.Orientation], chosen.Place.X, chosen.Place.Y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lower score wins; on a tie the larger box goes first (strict, so input order breaks full ties).</summary>
|
||||
private static bool Better(double p, double s, double area, double bp, double bs, double barea)
|
||||
{
|
||||
if (p < bp - MaxRectsSheet.Eps) return true;
|
||||
if (p > bp + MaxRectsSheet.Eps) return false;
|
||||
if (s < bs - MaxRectsSheet.Eps) return true;
|
||||
if (s > bs + MaxRectsSheet.Eps) return false;
|
||||
return area > barea + MaxRectsSheet.Eps;
|
||||
}
|
||||
|
||||
private static Box Union(Box a, Box b)
|
||||
{
|
||||
var l = System.Math.Min(a.Left, b.Left);
|
||||
var bo = System.Math.Min(a.Bottom, b.Bottom);
|
||||
var r = System.Math.Max(a.Right, b.Right);
|
||||
var t = System.Math.Max(a.Top, b.Top);
|
||||
return new Box(l, bo, r - l, t - bo);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user