mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 19:41:08 -04:00
Circles packs every part as its minimum enclosing circle at centers tangent to two of the work-area walls, placed discs and clamp keep-outs, bottom-left and left-bottom, with hexagonal and square lattice seeds. It bids only when every part fills at least 0.8 of its enclosing circle and otherwise returns everything unplaced at once. Default runs it as a third candidate. Rectangles' sheet-by-sheet stock loop moves to NestingEngines/SheetByStock (Rectangles output unchanged) and Default's candidate scoring and commit forwarding become shared helpers. Circles also runs the whole job preferring each offered stock and keeps the best result. Contract tests take shapes an engine can override. Synthetic round jobs, salvage-credited: Circles wins or ties all six with valid layouts; Default cost falls about 4% versus Irregular alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
8.8 KiB
C#
180 lines
8.8 KiB
C#
#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;
|
|
|
|
/// <summary>A requested part type as the stock loop sees it; <see cref="Index"/> addresses the
|
|
/// remaining-demand array.</summary>
|
|
internal sealed record SheetDemand(int Index, string Id, int Priority, double MaterialArea);
|
|
|
|
/// <summary>One proposed single-sheet layout from an engine's packer.</summary>
|
|
/// <param name="MaterialArea">Material placed, summed per type in type order so equal layouts get
|
|
/// bit-identical areas (placement-order rounding differs across platform math libraries).</param>
|
|
/// <param name="Envelope">Bounds of the placed material, or null to price the gross sheet.</param>
|
|
internal sealed record SheetLayout(
|
|
NestPlateStock Stock,
|
|
IReadOnlyList<(string PartId, double X, double Y, double Rotation)> Poses,
|
|
double MaterialArea,
|
|
Box? Envelope);
|
|
|
|
/// <summary>
|
|
/// Sheet-by-sheet stock selection shared by engines that propose whole single-sheet layouts.
|
|
/// Each round asks the engine for layouts of the remaining demand on every available stock and
|
|
/// commits the one with the lowest estimated whole-job cost: its salvage-credited net cost
|
|
/// (<see cref="NestJobCost"/>) plus the remaining demand priced at the best net-cost-per-material
|
|
/// ratio seen among the candidates. A completed layout repeats while demand and stock allow it.
|
|
/// Deterministic: no clocks or randomness; the only stop besides completion is the host token.
|
|
/// </summary>
|
|
internal static class SheetByStock
|
|
{
|
|
/// <param name="remaining">Demand per type index; updated as sheets commit. Zero a type the
|
|
/// engine can never place before calling.</param>
|
|
/// <param name="pack">Layouts of the given remaining demand on one stock. Each must be the
|
|
/// packer's completed search, not budget-limited output, so a repeat is safe.</param>
|
|
/// <param name="preferred">When set, only this stock is tried while it is available and
|
|
/// takes a layout; the others are tried only once it cannot.</param>
|
|
public static NestJobResult Run(
|
|
NestJob job,
|
|
IReadOnlyList<SheetDemand> types,
|
|
int[] remaining,
|
|
Func<NestPlateStock, IReadOnlyList<int>, IEnumerable<SheetLayout>> pack,
|
|
IProgress<NestJobProgress>? progress,
|
|
CancellationToken token,
|
|
Action<RepeatBatch>? batchSelected = null,
|
|
NestPlateStock? preferred = null)
|
|
{
|
|
var used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal);
|
|
var parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
|
|
var priority = types.ToDictionary(t => t.Id, t => t.Priority, StringComparer.Ordinal);
|
|
var result = new NestJobResultBuilder(job, progress);
|
|
NestJobStopReason reason;
|
|
|
|
while (true)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
if (remaining.All(r => r == 0))
|
|
{
|
|
reason = NestJobStopReason.NoPlacementFound; // Builder reports Completed when demand is met.
|
|
break;
|
|
}
|
|
if (job.Options.MaxPlates is int cap && SheetsUsed(result, job) >= cap)
|
|
{
|
|
reason = NestJobStopReason.PlateLimitReached;
|
|
break;
|
|
}
|
|
|
|
var trials = new List<(SheetLayout Layout, double Net)>();
|
|
if (preferred != null)
|
|
Try(preferred, trials);
|
|
if (trials.Count == 0)
|
|
foreach (var stock in job.Plates)
|
|
if (!ReferenceEquals(stock, preferred))
|
|
Try(stock, trials);
|
|
|
|
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, priority, trials);
|
|
var poses = chosen.Poses;
|
|
var demand = types.ToDictionary(t => t.Id, t => remaining[t.Index], StringComparer.Ordinal);
|
|
var counts = poses.GroupBy(p => p.PartId, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
|
|
var availableEntries = job.Plates.Count(s => s.Quantity == null || used[s.Id] < s.Quantity);
|
|
var copies = CompletedLayoutRepeat.Limit(demand, counts, searchCompleted: true, availableEntries,
|
|
chosen.Stock.Quantity - used[chosen.Stock.Id], job.Options.MaxPlates - SheetsUsed(result, job));
|
|
if (copies > 1)
|
|
{
|
|
try
|
|
{
|
|
NestJobValidator.ValidateCandidate(new PlateCandidate(poses.Select(p =>
|
|
new NestJobPlacement(p.PartId, 0, p.X, p.Y, p.Rotation))), chosen.Stock, demand, parts);
|
|
}
|
|
catch (InvalidOperationException)
|
|
{
|
|
// Keep the original one-sheet path if the candidate validator cannot certify
|
|
// reuse (including its stricter work-area tolerance). Final job validation still applies.
|
|
copies = 1;
|
|
}
|
|
}
|
|
batchSelected?.Invoke(new RepeatBatch(chosen.Stock.Id, copies, availableEntries, SheetsUsed(result, job), false));
|
|
for (var copy = 0; copy < copies; copy++)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
result.AddSheet(chosen.Stock, poses); // Builder owns fresh placements/instance indices.
|
|
}
|
|
used[chosen.Stock.Id] = checked(used[chosen.Stock.Id] + copies);
|
|
foreach (var type in types)
|
|
if (counts.TryGetValue(type.Id, out var count))
|
|
remaining[type.Index] = checked(remaining[type.Index] - checked(count * copies));
|
|
}
|
|
|
|
return result.Build(reason);
|
|
|
|
void Try(NestPlateStock stock, List<(SheetLayout Layout, double Net)> trials)
|
|
{
|
|
token.ThrowIfCancellationRequested();
|
|
if (stock.Quantity is int available && used[stock.Id] >= available)
|
|
return;
|
|
progress?.Report(new NestJobProgress(
|
|
NestJobStage.EvaluatingCandidate, stock.Id, SheetsUsed(result, job), SheetsUsed(result, job), 0));
|
|
foreach (var layout in pack(stock, remaining))
|
|
if (layout.Poses.Count > 0)
|
|
trials.Add((layout, NetCost(job, layout)));
|
|
}
|
|
}
|
|
|
|
public static int SheetsUsed(NestJobResultBuilder builder, NestJob job) =>
|
|
job.Plates.Sum(builder.SheetsUsed);
|
|
|
|
/// <summary>
|
|
/// Picks the layout with the lowest estimated whole-job cost. Remaining demand is priced at the
|
|
/// best net-cost-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 SheetLayout Choose(IReadOnlyList<SheetDemand> types, int[] remaining,
|
|
IReadOnlyDictionary<string, int> priority, List<(SheetLayout Layout, double Net)> trials)
|
|
{
|
|
var demandArea = types.Sum(t => remaining[t.Index] * t.MaterialArea);
|
|
var bestRatio = trials.Min(t => t.Net / System.Math.Max(t.Layout.MaterialArea, 1e-12));
|
|
return trials
|
|
.Select((t, order) => (t.Layout, order,
|
|
Estimate: NestJobCost.RequireFinite(t.Net + System.Math.Max(0, demandArea - t.Layout.MaterialArea) * bestRatio)))
|
|
.OrderBy(t => PriorityDebt(types, remaining, priority, t.Layout))
|
|
.ThenBy(t => t.Estimate)
|
|
.ThenByDescending(t => t.Layout.MaterialArea)
|
|
.ThenBy(t => t.order)
|
|
.First()
|
|
.Layout;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Priority guard: how many instances of the most urgent (lowest-number) tier with remaining
|
|
/// demand this layout leaves unplaced. Layouts 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<SheetDemand> types, int[] remaining,
|
|
IReadOnlyDictionary<string, int> priority, SheetLayout layout)
|
|
{
|
|
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 = layout.Poses.Count(p => priority[p.PartId] == top);
|
|
return active.Where(t => t.Priority == top).Sum(t => remaining[t.Index]) - placed;
|
|
}
|
|
|
|
private static double NetCost(NestJob job, SheetLayout layout) =>
|
|
layout.Envelope is { } envelope
|
|
? NestJobCost.NetSheetCost(job.Options, layout.Stock, envelope)
|
|
: NestJobCost.GrossSheetCost(layout.Stock);
|
|
}
|