mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-14 18:21:55 -04:00
fix(engine): solve each clamp removal on its own sheets within the stock pool
Phase 2 offered every removal variant to all residual demand in one solve, each variant as unlimited stock. A sheet could take more clamps off than its own parts needed (C11), and a finite pool could yield extra physical sheets (C12). One root cause, one repair: each variant is now solved separately, fewest removals first, with its own blocked parts plus unblocked residual demand; a sheet is kept only when it holds a part that needed exactly that removal; kept sheets come out of the base stock's remaining pool and the plate cap. The expanded job reduces each finite base by what its variants took, so validation sees the true inventory. Variants are cloned from the exact sheet-frame zones instead of a translate round trip. PM f91e054a-6f62-4080-91ed-2e1ce1f6eb7e; PM 245a555f-4f16-40b0-a3c9-430c661ad6a6
This commit is contained in:
1 parent
f19a825c4e
commit
b46310e01b
2 files changed
+169
-86
No files matched your search
@@ -180,4 +180,43 @@ public class ClampRemovalRepairTests
|
||||
Assert.All(result.Job.Plates, s => Assert.DoesNotContain("removed", s.Id));
|
||||
Assert.All(result.Raw.Plates, sheet => Assert.Single(sheet.Stock.ClampZones));
|
||||
}
|
||||
private static int RemovedCount(NestJobPlateResult sheet) => 2 - sheet.Stock.ClampZones.Count;
|
||||
|
||||
// PM f91e054a (C11): minimality holds per sheet. part-0 (22 wide) needs both clamps off,
|
||||
// part-1 (14 wide) only clamp-1; the sheet holding only part-1 must keep clamp-2.
|
||||
[Fact]
|
||||
public void EachSheetRemovesOnlyTheClampsItsOwnPartsNeed()
|
||||
{
|
||||
var result = NestPipeline.Run(Request(Stock(), Item("widest", 22, 3, 1), Item("wide", 14, 3, 1)));
|
||||
|
||||
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
Assert.Equal(2, result.Raw.Plates.Count);
|
||||
foreach (var sheet in result.Raw.Plates)
|
||||
{
|
||||
var needsBoth = sheet.Placements.Any(p => p.PartId == "part-0");
|
||||
Assert.Equal(needsBoth ? 2 : 1, RemovedCount(sheet));
|
||||
if (!needsBoth)
|
||||
Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2");
|
||||
}
|
||||
}
|
||||
|
||||
// PM 245a555f (C12): reduced-clamp sheets come out of the same finite pool.
|
||||
[Theory]
|
||||
[InlineData(3, false)] // three blocked parts, one physical sheet
|
||||
[InlineData(1, true)] // the ordinary part already used the only sheet
|
||||
public void RemovalNeverInventsSheetsBeyondTheFiniteStock(int wide, bool small)
|
||||
{
|
||||
var items = new List<NestItem> { Item("wide", 14, 3, wide) };
|
||||
if (small)
|
||||
items.Add(Item("small", 2, 1, 1));
|
||||
|
||||
var result = NestPipeline.Run(Request(Stock(quantity: 1), items.ToArray()));
|
||||
|
||||
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||
Assert.Single(result.Raw.Plates);
|
||||
Assert.Equal(1, result.Raw.StockUsage.Sum(u => u.Used));
|
||||
Assert.Equal(1, result.Job.Plates.Sum(s => s.Quantity ?? int.MaxValue));
|
||||
Assert.All(result.Raw.StockUsage, u => Assert.True(u.Remaining is null or >= 0));
|
||||
}
|
||||
}
|
||||
@@ -80,63 +80,105 @@ internal static class ClampRemovalRecovery
|
||||
return null;
|
||||
|
||||
// C11: per blocked part, the fewest clamps (smallest contiguous run, nearest the
|
||||
// origin on ties) whose removal frees an empty sheet of some offered stock.
|
||||
var recoveryVariants = new Dictionary<string, NestPlateStock>(StringComparer.Ordinal);
|
||||
var baseIdsNeedingRemoval = new HashSet<string>(StringComparer.Ordinal);
|
||||
// origin on ties) whose removal frees an empty sheet of some offered stock. Parts
|
||||
// needing the same removal on the same stock share one variant.
|
||||
var groups = new List<RemovalGroup>();
|
||||
foreach (var part in blocked)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var best = BestRemoval(part, availableStock, job.Options, engine, token);
|
||||
var best = BestRemoval(part, availableStock, job.Options, lanes, token);
|
||||
if (best == null)
|
||||
continue; // Unfit even with every clamp removed: stays unplaced, as today.
|
||||
recoveryVariants[best.Stock.Id] = best.Stock;
|
||||
baseIdsNeedingRemoval.Add(best.BaseStockId);
|
||||
var group = groups.FirstOrDefault(g => g.Removal.Stock.Id == best.Stock.Id);
|
||||
if (group == null)
|
||||
groups.Add(group = new RemovalGroup(best));
|
||||
group.BlockedIds.Add(part.Id);
|
||||
}
|
||||
if (recoveryVariants.Count == 0)
|
||||
if (groups.Count == 0)
|
||||
return null;
|
||||
|
||||
// Phase 2: solve the whole residual demand (not only the blocked parts - freed space
|
||||
// may also take other unmet parts, C12) on the untouched stock plus the reduced-clamp
|
||||
// variants. A base stock that needed removal is not also offered full-clamp here, so
|
||||
// a finite physical pool is never double-counted across both forms.
|
||||
var usedByStock = raw.StockUsage.ToDictionary(u => u.StockId, u => u.Used, StringComparer.Ordinal);
|
||||
var carryForward = availableStock
|
||||
.Where(s => !baseIdsNeedingRemoval.Contains(s.Id))
|
||||
.Select(s => ResidualCopy(s, usedByStock))
|
||||
.Where(s => s.Quantity != 0);
|
||||
var residualStock = carryForward.Concat(recoveryVariants.Values).ToList();
|
||||
// Phase 2, one removal variant at a time, fewest removed clamps first: each variant
|
||||
// takes its own blocked parts plus any other unmet demand that is not blocked (freed
|
||||
// space may take other parts, C12). A sheet is kept only when it holds a part that
|
||||
// needed exactly this removal (C10/C11 per sheet), and every kept sheet comes out of
|
||||
// its base stock's remaining physical pool (C12: a finite pool is never exceeded).
|
||||
var unmet = remaining.ToDictionary(x => x.Part.Id, x => x.Unplaced, StringComparer.Ordinal);
|
||||
var blockedIds = new HashSet<string>(groups.SelectMany(g => g.BlockedIds), StringComparer.Ordinal);
|
||||
var pool = availableStock.ToDictionary(
|
||||
s => s.Id,
|
||||
s => s.Quantity is int q ? q - raw.StockUsage.Where(u => u.StockId == s.Id).Sum(u => u.Used) : (int?)null,
|
||||
StringComparer.Ordinal);
|
||||
int? platesLeft = job.Options.MaxPlates is int cap ? cap - raw.Plates.Count : null;
|
||||
|
||||
int? residualMaxPlates = job.Options.MaxPlates is int cap
|
||||
? System.Math.Max(0, cap - raw.Plates.Count)
|
||||
: null;
|
||||
if (residualMaxPlates == 0)
|
||||
var recovered = new List<(NestPlateStock Variant, string BaseId, List<NestJobPlateResult> Sheets)>();
|
||||
foreach (var group in groups.OrderBy(g => g.Removal.RemovedIds.Count))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var baseId = group.Removal.BaseStockId;
|
||||
var available = pool[baseId];
|
||||
if (available <= 0 || platesLeft <= 0)
|
||||
continue;
|
||||
int? sheetCap = (available, platesLeft) switch
|
||||
{
|
||||
(int a, int p) => System.Math.Min(a, p),
|
||||
(int a, null) => a,
|
||||
(null, int p) => p,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
var parts = remaining
|
||||
.Where(x => unmet[x.Part.Id] > 0
|
||||
&& (group.BlockedIds.Contains(x.Part.Id) || !blockedIds.Contains(x.Part.Id)))
|
||||
.Select(x => new NestJobPart(x.Part.Id, x.Part.Geometry, unmet[x.Part.Id], x.Part.Priority, x.Part.Rotation))
|
||||
.ToList();
|
||||
if (!parts.Any(p => group.BlockedIds.Contains(p.Id)))
|
||||
continue;
|
||||
|
||||
var variant = Clone(group.Removal.Stock, group.Removal.Stock.Id, sheetCap, null);
|
||||
var options = new NestJobOptions(job.Options.PlacementStrategy, platesLeft,
|
||||
job.Options.SalvageRate, job.Options.MinimumSalvageDimension);
|
||||
var groupJob = new NestJob(parts, new[] { variant }, options);
|
||||
|
||||
List<NestJobPlateResult> kept = null;
|
||||
foreach (var lane in lanes)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var solved = lane.Solve(groupJob, null, token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
kept = solved?.Plates
|
||||
.Where(sheet => sheet.Placements.Any(p => group.BlockedIds.Contains(p.PartId)))
|
||||
.Take(sheetCap ?? int.MaxValue)
|
||||
.ToList();
|
||||
if (kept is { Count: > 0 })
|
||||
break;
|
||||
}
|
||||
if (kept is not { Count: > 0 })
|
||||
continue;
|
||||
|
||||
foreach (var placement in kept.SelectMany(sheet => sheet.Placements))
|
||||
unmet[placement.PartId]--;
|
||||
if (pool[baseId] is int left)
|
||||
pool[baseId] = left - kept.Count;
|
||||
if (platesLeft is int plates)
|
||||
platesLeft = plates - kept.Count;
|
||||
recovered.Add((group.Removal.Stock, baseId, kept));
|
||||
}
|
||||
if (recovered.Count == 0)
|
||||
return null;
|
||||
|
||||
var residualParts = remaining
|
||||
.Select(x => new NestJobPart(x.Part.Id, x.Part.Geometry, x.Unplaced, x.Part.Priority, x.Part.Rotation))
|
||||
.ToList();
|
||||
var residualOptions = new NestJobOptions(
|
||||
job.Options.PlacementStrategy,
|
||||
residualMaxPlates,
|
||||
job.Options.SalvageRate,
|
||||
job.Options.MinimumSalvageDimension
|
||||
);
|
||||
var residualJob = new NestJob(residualParts, residualStock, residualOptions);
|
||||
return Merge(job, raw, recovered);
|
||||
}
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
var raw2 = engine.Solve(residualJob, null, token);
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (raw2 == null || raw2.Plates.Count == 0)
|
||||
return null;
|
||||
|
||||
var merged = Merge(job, raw, recoveryVariants.Values, raw2);
|
||||
return merged;
|
||||
private sealed class RemovalGroup(Removal removal)
|
||||
{
|
||||
public Removal Removal { get; } = removal;
|
||||
public HashSet<string> BlockedIds { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Engines whose placement of a lone part proves it fits (C10): the caller's own engine,
|
||||
/// plus the built-in Irregular (NFP, true material) and Rectangles lanes. Default already
|
||||
/// runs both, so it is enough on its own.
|
||||
/// runs both, so it is enough on its own. Phase 2 tries them in this order too.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<INestingEngine> FeasibilityLanes(INestingEngine engine)
|
||||
{
|
||||
@@ -150,22 +192,15 @@ internal static class ClampRemovalRecovery
|
||||
return lanes;
|
||||
}
|
||||
|
||||
/// <summary>Same stock, quantity reduced by what phase 1 already used (unlimited stays
|
||||
/// unlimited); id/zones unchanged. Used for stock phase 2 may still draw from.</summary>
|
||||
private static NestPlateStock ResidualCopy(NestPlateStock stock, IReadOnlyDictionary<string, int> usedByStock) =>
|
||||
stock.Quantity is int quantity
|
||||
? Clone(stock, stock.Id, System.Math.Max(0, quantity - usedByStock.GetValueOrDefault(stock.Id)), null)
|
||||
: stock;
|
||||
|
||||
private sealed record Removal(NestPlateStock Stock, string BaseStockId, IReadOnlyList<string> RemovedIds);
|
||||
|
||||
/// <summary>The (stock, removal) with the fewest removed clamps across every offered
|
||||
/// stock that has any; ties prefer the stock listed earliest.</summary>
|
||||
/// rail-backed stock; ties prefer the stock listed earliest.</summary>
|
||||
private static Removal BestRemoval(
|
||||
NestJobPart part,
|
||||
IReadOnlyList<NestPlateStock> stocks,
|
||||
NestJobOptions options,
|
||||
INestingEngine engine,
|
||||
IReadOnlyList<INestingEngine> lanes,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
@@ -175,7 +210,7 @@ internal static class ClampRemovalRecovery
|
||||
if (!CanRemoveClamps(stock))
|
||||
continue;
|
||||
token.ThrowIfCancellationRequested();
|
||||
var found = MinimalRemovalForStock(part, stock, options, engine, token);
|
||||
var found = MinimalRemovalForStock(part, stock, options, lanes, token);
|
||||
if (found == null)
|
||||
continue;
|
||||
if (best == null || found.RemovedIds.Count < best.RemovedIds.Count)
|
||||
@@ -192,14 +227,14 @@ internal static class ClampRemovalRecovery
|
||||
/// <summary>
|
||||
/// Ascending run size, then ascending start index: the first contiguous run of the
|
||||
/// stock's own clamp zones, ordered by distance from the origin along the edge, whose
|
||||
/// removal lets the part fit alone is both the fewest clamps and, among ties, the run
|
||||
/// nearest the origin.
|
||||
/// removal lets the part fit alone (in any lane) is both the fewest clamps and, among
|
||||
/// ties, the run nearest the origin.
|
||||
/// </summary>
|
||||
private static Removal MinimalRemovalForStock(
|
||||
NestJobPart part,
|
||||
NestPlateStock stock,
|
||||
NestJobOptions options,
|
||||
INestingEngine engine,
|
||||
IReadOnlyList<INestingEngine> lanes,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
@@ -215,7 +250,7 @@ internal static class ClampRemovalRecovery
|
||||
token.ThrowIfCancellationRequested();
|
||||
var removedIds = zones.Skip(start).Take(run).Select(z => z.Id).ToList();
|
||||
var candidate = Clone(stock, Suffixed(stock.Id, removedIds), null, removedIds);
|
||||
if (FitsAlone(part, candidate, options, engine, token))
|
||||
if (lanes.Any(lane => FitsAlone(part, candidate, options, lane, token)))
|
||||
return new Removal(candidate, stock.Id, removedIds);
|
||||
}
|
||||
}
|
||||
@@ -239,8 +274,7 @@ internal static class ClampRemovalRecovery
|
||||
) => Probe(part, stocks, options, engine, token);
|
||||
|
||||
/// <summary>Whether this part, alone and at quantity one, places on an empty sheet of any
|
||||
/// of the given stock entries. Uses the caller's own selected engine, so the answer
|
||||
/// matches whatever lane/rotation handling Auto Nest would actually use.</summary>
|
||||
/// of the given stock entries with the given engine.</summary>
|
||||
private static bool Probe(
|
||||
NestJobPart part,
|
||||
IReadOnlyList<NestPlateStock> stocks,
|
||||
@@ -265,10 +299,8 @@ internal static class ClampRemovalRecovery
|
||||
|
||||
/// <summary>
|
||||
/// A copy of <paramref name="stock"/> with a new id/quantity and, when
|
||||
/// <paramref name="removedIds"/> is given, those clamp zones dropped. Stock carries its
|
||||
/// zones already translated into the placement frame; they are translated back to plate
|
||||
/// coordinates and re-translated forward so the result matches what
|
||||
/// <see cref="NestPlateStock"/>'s own constructor would produce from a plate.
|
||||
/// <paramref name="removedIds"/> is given, those clamp zones dropped. Built from the
|
||||
/// stock's own sheet-frame zones, so the kept zones are bit-identical to the original's.
|
||||
/// </summary>
|
||||
private static NestPlateStock Clone(
|
||||
NestPlateStock stock,
|
||||
@@ -277,11 +309,8 @@ internal static class ClampRemovalRecovery
|
||||
IReadOnlyCollection<string> removedIds
|
||||
)
|
||||
{
|
||||
var left = stock.Quadrant is 1 or 4 ? 0 : -stock.Size.Length;
|
||||
var bottom = stock.Quadrant is 1 or 2 ? 0 : -stock.Size.Width;
|
||||
var plateFrameZones = stock.ClampZones
|
||||
var sheetZones = stock.SheetClampZones
|
||||
.Where(z => removedIds == null || !removedIds.Contains(z.Id))
|
||||
.Select(z => new ClampZone(z.Id, z.Bounds.Translate(-left, -bottom)))
|
||||
.ToList();
|
||||
return new NestPlateStock(
|
||||
id,
|
||||
@@ -291,7 +320,7 @@ internal static class ClampRemovalRecovery
|
||||
stock.EdgeSpacing,
|
||||
stock.Quadrant,
|
||||
stock.Cost,
|
||||
plateFrameZones
|
||||
sheetZones
|
||||
)
|
||||
{ ClampRail = stock.ClampRail };
|
||||
}
|
||||
@@ -300,31 +329,40 @@ internal static class ClampRemovalRecovery
|
||||
$"{stockId}::removed:{string.Join(",", removedIds)}";
|
||||
|
||||
/// <summary>
|
||||
/// Combines the ordinary solve with the residual recovery solve into one result, and the
|
||||
/// expanded job both were validated against (the caller's stock plus the recovery
|
||||
/// variants actually used). Phase 2 placements get fresh instance indices continuing on
|
||||
/// from phase 1's, so no requirement's instances collide across the two solves.
|
||||
/// Combines the ordinary solve with the kept recovery sheets into one result, and the
|
||||
/// expanded job it validates against: the caller's stock with each finite base reduced
|
||||
/// by the sheets its variants took, plus each variant with exactly the sheets it used
|
||||
/// (unlimited when its base is), so the expanded inventory equals the physical one.
|
||||
/// Recovery placements get fresh instance indices continuing on from phase 1's.
|
||||
/// </summary>
|
||||
private static Outcome Merge(
|
||||
NestJob job,
|
||||
NestJobResult raw,
|
||||
IEnumerable<NestPlateStock> recoveryVariants,
|
||||
NestJobResult raw2
|
||||
IReadOnlyList<(NestPlateStock Variant, string BaseId, List<NestJobPlateResult> Sheets)> recovered
|
||||
)
|
||||
{
|
||||
var placedSoFar = raw.Fulfillment.ToDictionary(f => f.PartId, f => f.Placed, StringComparer.Ordinal);
|
||||
var nextPlateIndex = raw.Plates.Count;
|
||||
var reindexed = new List<NestJobPlateResult>(raw2.Plates.Count);
|
||||
foreach (var sheet in raw2.Plates)
|
||||
var reindexed = new List<NestJobPlateResult>();
|
||||
var variantStock = new List<NestPlateStock>();
|
||||
var takenFromBase = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var (variant, baseId, sheets) in recovered)
|
||||
{
|
||||
var placements = new List<NestJobPlacement>(sheet.Placements.Count);
|
||||
foreach (var pose in sheet.Placements)
|
||||
var baseStock = job.Plates.First(s => s.Id == baseId);
|
||||
var used = Clone(variant, variant.Id, baseStock.Quantity == null ? null : sheets.Count, null);
|
||||
variantStock.Add(used);
|
||||
takenFromBase[baseStock.Id] = takenFromBase.GetValueOrDefault(baseStock.Id) + sheets.Count;
|
||||
foreach (var sheet in sheets)
|
||||
{
|
||||
var index = placedSoFar.GetValueOrDefault(pose.PartId);
|
||||
placedSoFar[pose.PartId] = index + 1;
|
||||
placements.Add(pose with { InstanceIndex = index });
|
||||
var placements = new List<NestJobPlacement>(sheet.Placements.Count);
|
||||
foreach (var pose in sheet.Placements)
|
||||
{
|
||||
var index = placedSoFar.GetValueOrDefault(pose.PartId);
|
||||
placedSoFar[pose.PartId] = index + 1;
|
||||
placements.Add(pose with { InstanceIndex = index });
|
||||
}
|
||||
reindexed.Add(new NestJobPlateResult(nextPlateIndex++, used, placements));
|
||||
}
|
||||
reindexed.Add(new NestJobPlateResult(nextPlateIndex++, sheet.Stock, placements));
|
||||
}
|
||||
|
||||
var allPlates = raw.Plates.Concat(reindexed).ToList();
|
||||
@@ -336,16 +374,22 @@ internal static class ClampRemovalRecovery
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var used = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var used1 = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var u in raw.StockUsage)
|
||||
used[u.StockId] = u.Used;
|
||||
foreach (var sheet in reindexed)
|
||||
used[sheet.StockId] = used.GetValueOrDefault(sheet.StockId) + 1;
|
||||
used1[u.StockId] = used1.GetValueOrDefault(u.StockId) + u.Used;
|
||||
|
||||
var expandedStock = job.Plates.Concat(recoveryVariants).ToList();
|
||||
var expandedStock = job.Plates
|
||||
.Select(s => s.Quantity is int q && takenFromBase.TryGetValue(s.Id, out var taken)
|
||||
? Clone(s, s.Id, q - taken, null)
|
||||
: s)
|
||||
.Concat(variantStock)
|
||||
.ToList();
|
||||
var stockUsage = expandedStock
|
||||
.Select(s => new StockUsage(s.Id, used.GetValueOrDefault(s.Id),
|
||||
s.Quantity is int q ? q - used.GetValueOrDefault(s.Id) : null))
|
||||
.Select(s =>
|
||||
{
|
||||
var usedCount = used1.GetValueOrDefault(s.Id) + reindexed.Count(p => p.StockId == s.Id);
|
||||
return new StockUsage(s.Id, usedCount, s.Quantity is int q ? q - usedCount : null);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var complete = fulfillment.All(f => f.Unplaced == 0);
|
||||
|
||||
Reference in new issue
Block a user