mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-14 16:01:55 -04:00
feat(engine): remove clamps only for parts that cannot fit with them
Slice 3b task 2 (D8/C10-C12). New OpenNest.Engine.Jobs.ClampRemovalRecovery: after the ordinary clamped solve leaves demand unmet, probes (via the caller's own selected engine, quantity one, one fresh empty sheet) whether each unplaced part fits on any offered stock with every clamp in place; only a part that fits nowhere is 'blocked' (C10). For a blocked part, searches ascending run size then ascending start index over each stock's own clamp zones (already rail-position order) for the smallest contiguous run whose removal lets it fit alone - fewest clamps first, nearest the origin on ties (C11). Builds reduced-clamp stock variants for every resolved (part, stock), re-solves the whole residual demand (so freed space can also take other unmet parts, C12) with original stock carried forward minus phase 1's usage (a finite pool is never offered both full and reduced, so it is never double counted) and merges the result back: phase 2 placements get fresh instance indices continuing from phase 1's, plates are reindexed, and fulfillment/stock usage are recomputed. Returns null (no-op) whenever the job is already complete, no stock has any clamp, no remaining part is genuinely blocked, or even every clamp removed cannot fit a blocked part - so an ordinary job is bit-identical to Slice 3 (C10's last sentence). NestPipeline.RunCore calls it once, right after the main solve, only on the non-cutout-preview path; a recovery replaces 'raw' and expands 'job' (adding exactly the reduced-clamp variants used) before the existing validation/cost/grouping/binding code runs unchanged. NestPipelineCommit.SyncClampZones now derives which rail clamps (if any) are missing from a committed sheet's solved zones and, when the rail recreates exactly that reduced set, reattaches the rail with Plate.SetClampRail(rail, removedClamps) - so a recovered sheet keeps following the rail, minus its removal, through later resizes (C13), using Slice 3b task 1's plumbing. Tests: 7 new (OpenNest.Engine.Tests/Jobs/ClampRemovalRecoveryTests.cs, ClampRemovalCommitTests.cs) covering the minimal-removal + nearest- origin tie-break, freed space taking other residual demand (C12), a no-op on an already-complete job, no-op with no clamps offered, a part blocked only by stock exhaustion never being treated as clamp- blocked, a part unfit even clamp-free staying unplaced, and the full Auto-Nest-to-commit path keeping Plate.RemovedClamps/ClampRail/ ClampRemovalMinutes correct through a resize. Mutation checks (disabling the FitsAnywhere gate; reversing the ascending-start tie- break) turn the relevant assertions red, confirming real coverage. Gates: Release OpenNest.Tests 3884 passed/31 skipped, OpenNest.IO.Tests 105 passed, OpenNest.Engine.Tests 878 passed/2 skipped; desktop cross-build (-p:EnableWindowsTargeting=true) clean; scoped dotnet format --verify-no-changes clean. Desktop surface (removed-clamp display, Auto Nest summary, cut-time minutes, Clamps dialog removal-minutes field) is Slice 3b task 3, not yet done.
This commit is contained in:
1 parent
ec058d270c
commit
83c77269a1
5 files changed
+519
-4
No files matched your search
@@ -0,0 +1,48 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// D8/C13: a plate Auto Nest commits with a last-resort clamp removal keeps following its
|
||||
/// rail - minus the removed clamp - through later resizes, exactly like an ordinary
|
||||
/// rail-following sheet (Slice 3), and the removal itself is real, inspectable plate state.
|
||||
/// </summary>
|
||||
public class ClampRemovalCommitTests
|
||||
{
|
||||
[Fact]
|
||||
public void CommittedPlateKeepsFollowingTheRailMinusTheRemovedClamp()
|
||||
{
|
||||
// Same geometry as ClampRemovalRecoveryTests.BlockedPartRemovesOnlyTheNearestOriginCoveredClamp:
|
||||
// clamps 15" apart on a 23"-long, 3"-wide sheet; a 14"-wide part can only clear both by
|
||||
// removing one, and "clamp-1" (nearest the origin) wins the tie.
|
||||
var drawing = new Drawing("wide", TestDrawingFactory.Rectangle(14, 3));
|
||||
var nest = new Nest();
|
||||
nest.Drawings.Add(drawing);
|
||||
nest.PlateDefaults.Size = new Size(3, 23);
|
||||
nest.PlateDefaults.EdgeSpacing = new Spacing(0, 0, 0, 0);
|
||||
nest.SetClampRail(new ClampRail(1.5, 0.625, new[] { 6.0, 21.0 }));
|
||||
nest.CreatePlate(); // empty template the commit fills
|
||||
using var manager = new PlateManager(nest);
|
||||
|
||||
var request = new NestPipelineRequest("Rectangles",
|
||||
new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
|
||||
NestStockBuilder.SinglePlate(nest.Plates[0]));
|
||||
var result = NestPipeline.Run(request);
|
||||
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||
|
||||
var applied = NestPipelineCommit.ApplyToEmptyPlates(result, manager);
|
||||
var plate = Assert.Single(applied);
|
||||
|
||||
Assert.Equal(new[] { "clamp-1" }, plate.RemovedClamps);
|
||||
Assert.NotNull(plate.ClampRail);
|
||||
Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-1");
|
||||
Assert.Contains(plate.ClampZones, z => z.Id == "clamp-2");
|
||||
Assert.Equal(ClampRail.DefaultRemovalMinutes, plate.ClampRemovalMinutes); // 1 clamp removed
|
||||
|
||||
// C13: still follows the rail (minus the removal) through a resize/rotate.
|
||||
plate.Size = new Size(3, 60);
|
||||
Assert.DoesNotContain(plate.ClampZones, z => z.Id == "clamp-1");
|
||||
Assert.Contains(plate.ClampZones, z => z.Id == "clamp-2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Engine.NestingEngines.Rectangles;
|
||||
using OpenNest.Engine.Tests.NestingEngines;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// D8/D9 last-resort clamp removal in the whole-job pipeline: a part blocked by clamps on
|
||||
/// every offered stock gets the fewest clamps removed that let it fit (C10/C11), and the
|
||||
/// residual solve is merged back into one result; everything else is untouched (C10's
|
||||
/// bit-identical guarantee for an unblocked job).
|
||||
/// </summary>
|
||||
public class ClampRemovalRecoveryTests
|
||||
{
|
||||
// Two clamps 15" apart on a 23"-long, 3"-wide sheet (no edge/part spacing, so the math
|
||||
// below is exact): clamp-1 spans X [5.25, 6.75], clamp-2 spans X [20.25, 21.75]. A 3"-tall
|
||||
// part exactly fills the sheet's Y extent, so it always sits at Y = 0 - through the clamp
|
||||
// band - for whatever X it lands at.
|
||||
private static ClampZone Clamp1() => new("clamp-1", new Box(5.25, 0, 1.5, 0.625));
|
||||
private static ClampZone Clamp2() => new("clamp-2", new Box(20.25, 0, 1.5, 0.625));
|
||||
|
||||
private static NestPlateStock ClampedSheet(params ClampZone[] zones) =>
|
||||
JobBuilder.Stock("sheet", width: 3, length: 23, clampZones: zones);
|
||||
|
||||
private static readonly RectanglesNestingEngine Engine = new();
|
||||
|
||||
[Fact]
|
||||
public void BlockedPartRemovesOnlyTheNearestOriginCoveredClamp()
|
||||
{
|
||||
// A 14"-wide part: with both clamps up, no X position in [0, 9] clears both (the gap
|
||||
// between them is 13.5" < 14"), so it is genuinely blocked (C10), not merely unlucky.
|
||||
var part = JobBuilder.Rectangle("p", w: 14, h: 3, count: 1);
|
||||
var stock = ClampedSheet(Clamp1(), Clamp2());
|
||||
var job = JobBuilder.Job(new[] { part }, new[] { stock });
|
||||
|
||||
var raw = Engine.Solve(job);
|
||||
Assert.Equal(NestJobStatus.Incomplete, raw.Status); // sanity: today's solve fails
|
||||
|
||||
var outcome = ClampRemovalRecovery.TryRecover(job, raw, Engine, default);
|
||||
|
||||
Assert.NotNull(outcome);
|
||||
Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status);
|
||||
var sheet = Assert.Single(outcome.Result.Plates);
|
||||
Assert.Single(sheet.Placements);
|
||||
// Removing either clamp alone would free enough room; "clamp-1" is nearest the origin.
|
||||
Assert.DoesNotContain(sheet.Stock.ClampZones, z => z.Id == "clamp-1");
|
||||
Assert.Contains(sheet.Stock.ClampZones, z => z.Id == "clamp-2");
|
||||
var fulfillment = Assert.Single(outcome.Result.Fulfillment);
|
||||
Assert.Equal(0, fulfillment.Unplaced);
|
||||
// The expanded job the result validates against offers the original stock plus
|
||||
// exactly the reduced-clamp variant actually used.
|
||||
Assert.Equal(2, outcome.Job.Plates.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RemovalFreesSpaceForOtherResidualDemandToo()
|
||||
{
|
||||
// C12: a sheet nested with a clamp removed may also take other residual parts.
|
||||
var blocked = JobBuilder.Rectangle("wide", w: 14, h: 3, count: 1);
|
||||
var filler = JobBuilder.Rectangle("filler", w: 2, h: 1, count: 1);
|
||||
var stock = ClampedSheet(Clamp1(), Clamp2());
|
||||
var job = JobBuilder.Job(new[] { blocked, filler }, new[] { stock });
|
||||
|
||||
var raw = Engine.Solve(job);
|
||||
var outcome = ClampRemovalRecovery.TryRecover(job, raw, Engine, default);
|
||||
|
||||
Assert.NotNull(outcome);
|
||||
Assert.Equal(NestJobStatus.Complete, outcome!.Result.Status);
|
||||
Assert.All(outcome.Result.Fulfillment, f => Assert.Equal(0, f.Unplaced));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlreadyCompleteResultIsUntouched()
|
||||
{
|
||||
// C10: no blocked part exists, so recovery is a strict no-op (bit-identical to Slice 3).
|
||||
var part = JobBuilder.Rectangle("p", w: 1, h: 1, count: 1);
|
||||
var stock = ClampedSheet(Clamp1(), Clamp2());
|
||||
var job = JobBuilder.Job(new[] { part }, new[] { stock });
|
||||
var raw = Engine.Solve(job);
|
||||
Assert.Equal(NestJobStatus.Complete, raw.Status);
|
||||
|
||||
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NoClampsOfferedNeverRecovers()
|
||||
{
|
||||
var part = JobBuilder.Rectangle("p", w: 14, h: 3, count: 1);
|
||||
var stock = JobBuilder.Stock("sheet", width: 3, length: 10); // too short, no clamps
|
||||
var job = JobBuilder.Job(new[] { part }, new[] { stock });
|
||||
var raw = Engine.Solve(job);
|
||||
Assert.NotEqual(NestJobStatus.Complete, raw.Status);
|
||||
|
||||
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PartBlockedOnlyByStockExhaustionIsNeverTreatedAsClampBlocked()
|
||||
{
|
||||
// The part fits a fresh clamped sheet on its own; only a single-sheet cap stops the
|
||||
// second copy. That is a capacity limit, not a clamp block, so nothing is removed.
|
||||
var part = JobBuilder.Rectangle("p", w: 12, h: 3, count: 2);
|
||||
var stock = JobBuilder.Stock("sheet", width: 3, length: 23, quantity: 1,
|
||||
clampZones: new[] { Clamp1(), Clamp2() });
|
||||
var job = JobBuilder.Job(new[] { part }, new[] { stock });
|
||||
|
||||
var raw = Engine.Solve(job);
|
||||
Assert.Equal(NestJobStatus.Incomplete, raw.Status);
|
||||
Assert.Equal(NestJobStopReason.StockExhausted, raw.StopReason);
|
||||
|
||||
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnfitEvenWithoutAnyClampStaysUnplaced()
|
||||
{
|
||||
// Wider than the whole sheet: removing every clamp still cannot make it fit.
|
||||
var part = JobBuilder.Rectangle("p", w: 30, h: 3, count: 1);
|
||||
var stock = ClampedSheet(Clamp1(), Clamp2());
|
||||
var job = JobBuilder.Job(new[] { part }, new[] { stock });
|
||||
var raw = Engine.Solve(job);
|
||||
Assert.Equal(NestJobStatus.Incomplete, raw.Status);
|
||||
|
||||
Assert.Null(ClampRemovalRecovery.TryRecover(job, raw, Engine, default));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// D8/D9 last-resort clamp removal for the whole-job pipeline. After a normal clamped solve
|
||||
/// leaves demand unmet, finds parts that cannot fit anywhere with every clamp in place (C10),
|
||||
/// computes the fewest clamps whose removal lets each one fit (C11), and re-solves the
|
||||
/// residual demand on stock variants with those clamps taken off (C12). Never changes the
|
||||
/// result when nothing is blocked (C10's bit-identical guarantee) and never trades a removal
|
||||
/// for material savings - it only runs after the ordinary solve could not place a part.
|
||||
/// </summary>
|
||||
internal static class ClampRemovalRecovery
|
||||
{
|
||||
/// <summary>A result that replaces the caller's raw solve, and the expanded job it was
|
||||
/// validated against (the caller's own stock plus the reduced-clamp variants used).</summary>
|
||||
internal sealed record Outcome(NestJob Job, NestJobResult Result);
|
||||
|
||||
/// <summary>
|
||||
/// Null when there is nothing to recover: the job is already complete, no offered stock
|
||||
/// has any clamp, no remaining part is genuinely clamp-blocked, or removing clamps did
|
||||
/// not let anything new fit. Callers keep their original job/result unchanged in every
|
||||
/// one of those cases (C10).
|
||||
/// </summary>
|
||||
internal static Outcome TryRecover(
|
||||
NestJob job,
|
||||
NestJobResult raw,
|
||||
INestingEngine engine,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
ArgumentNullException.ThrowIfNull(raw);
|
||||
ArgumentNullException.ThrowIfNull(engine);
|
||||
|
||||
if (raw.Status == NestJobStatus.Complete)
|
||||
return null;
|
||||
|
||||
var availableStock = job.Plates.Where(s => s.Quantity != 0).ToList();
|
||||
if (!availableStock.Any(s => s.ClampZones.Count > 0))
|
||||
return null;
|
||||
|
||||
var partsById = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal);
|
||||
var remaining = raw.Fulfillment
|
||||
.Where(f => f.Unplaced > 0)
|
||||
.Select(f => (Part: partsById[f.PartId], Unplaced: f.Unplaced))
|
||||
.ToList();
|
||||
if (remaining.Count == 0)
|
||||
return null;
|
||||
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// C10: only a part that fits nowhere with every clamp in place is "blocked".
|
||||
var blocked = remaining
|
||||
.Where(x => !FitsAnywhere(x.Part, availableStock, job.Options, engine, token))
|
||||
.Select(x => x.Part)
|
||||
.ToList();
|
||||
if (blocked.Count == 0)
|
||||
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);
|
||||
foreach (var part in blocked)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
var best = BestRemoval(part, availableStock, job.Options, engine, 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);
|
||||
}
|
||||
if (recoveryVariants.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();
|
||||
|
||||
int? residualMaxPlates = job.Options.MaxPlates is int cap
|
||||
? System.Math.Max(0, cap - raw.Plates.Count)
|
||||
: null;
|
||||
if (residualMaxPlates == 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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
private static Removal BestRemoval(
|
||||
NestJobPart part,
|
||||
IReadOnlyList<NestPlateStock> stocks,
|
||||
NestJobOptions options,
|
||||
INestingEngine engine,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
Removal best = null;
|
||||
foreach (var stock in stocks)
|
||||
{
|
||||
if (stock.ClampZones.Count == 0)
|
||||
continue;
|
||||
token.ThrowIfCancellationRequested();
|
||||
var found = MinimalRemovalForStock(part, stock, options, engine, token);
|
||||
if (found == null)
|
||||
continue;
|
||||
if (best == null || found.RemovedIds.Count < best.RemovedIds.Count)
|
||||
best = found;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ascending run size, then ascending start index: the first contiguous run of the
|
||||
/// stock's own clamp zones (already ordered by rail position) whose removal lets the
|
||||
/// part fit alone 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,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
var zones = stock.ClampZones;
|
||||
for (var run = 1; run <= zones.Count; run++)
|
||||
{
|
||||
for (var start = 0; start + run <= zones.Count; start++)
|
||||
{
|
||||
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))
|
||||
return new Removal(candidate, stock.Id, removedIds);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool FitsAlone(
|
||||
NestJobPart part,
|
||||
NestPlateStock stock,
|
||||
NestJobOptions options,
|
||||
INestingEngine engine,
|
||||
CancellationToken token
|
||||
) => Probe(part, new[] { stock }, options, engine, token);
|
||||
|
||||
private static bool FitsAnywhere(
|
||||
NestJobPart part,
|
||||
IReadOnlyList<NestPlateStock> stocks,
|
||||
NestJobOptions options,
|
||||
INestingEngine engine,
|
||||
CancellationToken token
|
||||
) => 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>
|
||||
private static bool Probe(
|
||||
NestJobPart part,
|
||||
IReadOnlyList<NestPlateStock> stocks,
|
||||
NestJobOptions options,
|
||||
INestingEngine engine,
|
||||
CancellationToken token
|
||||
)
|
||||
{
|
||||
var probePart = new NestJobPart(part.Id, part.Geometry, 1, part.Priority, part.Rotation);
|
||||
var probeStock = stocks.Select(s => Unlimited(s)).ToList();
|
||||
var probeOptions = new NestJobOptions(options.PlacementStrategy, 1, 0, 0);
|
||||
var probeJob = new NestJob(new[] { probePart }, probeStock, probeOptions);
|
||||
token.ThrowIfCancellationRequested();
|
||||
var result = engine.Solve(probeJob, null, token);
|
||||
return result != null && result.Status == NestJobStatus.Complete;
|
||||
}
|
||||
|
||||
/// <summary>Same stock with unlimited quantity (feasibility probes never consume real
|
||||
/// inventory); id/zones unchanged.</summary>
|
||||
private static NestPlateStock Unlimited(NestPlateStock stock) =>
|
||||
stock.Quantity == null ? stock : Clone(stock, stock.Id, null, null);
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
private static NestPlateStock Clone(
|
||||
NestPlateStock stock,
|
||||
string id,
|
||||
int? quantity,
|
||||
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
|
||||
.Where(z => removedIds == null || !removedIds.Contains(z.Id))
|
||||
.Select(z => new ClampZone(z.Id, z.Bounds.Translate(-left, -bottom)))
|
||||
.ToList();
|
||||
return new NestPlateStock(
|
||||
id,
|
||||
stock.Size,
|
||||
quantity,
|
||||
stock.PartSpacing,
|
||||
stock.EdgeSpacing,
|
||||
stock.Quadrant,
|
||||
stock.Cost,
|
||||
plateFrameZones
|
||||
);
|
||||
}
|
||||
|
||||
private static string Suffixed(string stockId, IReadOnlyList<string> removedIds) =>
|
||||
$"{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.
|
||||
/// </summary>
|
||||
private static Outcome Merge(
|
||||
NestJob job,
|
||||
NestJobResult raw,
|
||||
IEnumerable<NestPlateStock> recoveryVariants,
|
||||
NestJobResult raw2
|
||||
)
|
||||
{
|
||||
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 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++, sheet.Stock, placements));
|
||||
}
|
||||
|
||||
var allPlates = raw.Plates.Concat(reindexed).ToList();
|
||||
var fulfillment = job.Parts
|
||||
.Select(p =>
|
||||
{
|
||||
var placed = placedSoFar.GetValueOrDefault(p.Id);
|
||||
return new PartFulfillment(p.Id, p.Quantity, placed, p.Quantity - placed);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var used = 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;
|
||||
|
||||
var expandedStock = job.Plates.Concat(recoveryVariants).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))
|
||||
.ToList();
|
||||
|
||||
var complete = fulfillment.All(f => f.Unplaced == 0);
|
||||
var status = complete ? NestJobStatus.Complete : raw.Status;
|
||||
var stopReason = complete ? NestJobStopReason.Completed : raw.StopReason;
|
||||
|
||||
var expandedJob = new NestJob(job.Parts, expandedStock, job.Options);
|
||||
var result = new NestJobResult(status, stopReason, allPlates, fulfillment, stockUsage);
|
||||
return new Outcome(expandedJob, result);
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,20 @@ public static class NestPipeline
|
||||
var solveTime = clock.Elapsed;
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
// D8/C10: only the main (non-cutout-preview) path offers last-resort clamp removal.
|
||||
// No-op whenever nothing is blocked, so an ordinary solve is unaffected (C10).
|
||||
if (prepass == null)
|
||||
{
|
||||
var recovered = ClampRemovalRecovery.TryRecover(job, raw, engine, token);
|
||||
if (recovered != null)
|
||||
{
|
||||
job = recovered.Job;
|
||||
engineJob = recovered.Job;
|
||||
raw = recovered.Result;
|
||||
}
|
||||
}
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
clock.Restart();
|
||||
var names = drawingsByPartId.ToDictionary(
|
||||
kv => kv.Key,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
@@ -59,8 +60,9 @@ public static class NestPipelineCommit
|
||||
/// against, including sheets created for this commit. Stock zones live in the placement
|
||||
/// frame; plate zones in plate coordinates, so the stock's translation is inverted. Zones
|
||||
/// already equal in the placement frame are left untouched (no change events). Replaced
|
||||
/// zones stay tied to the plate's clamp rail only when that rail recreates them;
|
||||
/// otherwise a later size or quadrant assignment would silently swap in other clamps.
|
||||
/// zones stay tied to the plate's clamp rail only when that rail recreates them (directly,
|
||||
/// or minus whichever clamps Auto Nest removed as a last resort, D8/C13); otherwise a
|
||||
/// later size or quadrant assignment would silently swap in other clamps.
|
||||
/// </summary>
|
||||
private static void SyncClampZones(Plate plate, NestPlateStock stock)
|
||||
{
|
||||
@@ -73,8 +75,19 @@ public static class NestPipelineCommit
|
||||
plate.SetClampRail(null); // explicit zones from here on
|
||||
foreach (var zone in stock.ClampZones)
|
||||
plate.ClampZones.Add(new ClampZone(zone.Id, zone.Bounds.Translate(-left, -bottom)));
|
||||
if (rail != null && rail.Matches(plate))
|
||||
plate.SetClampRail(rail);
|
||||
if (rail == null)
|
||||
return;
|
||||
|
||||
// D8: the sheet's zones may be the rail's resolution for it minus clamps Auto Nest
|
||||
// removed as a last resort; derive which (if any) so the committed plate keeps
|
||||
// following the rail - and the removal - through later resizes (C13).
|
||||
var kept = new HashSet<string>(plate.ClampZones.Select(z => z.Id), StringComparer.Ordinal);
|
||||
var removedClamps = rail.ResolveFor(plate.Size, plate.Quadrant)
|
||||
.Select(z => z.Id)
|
||||
.Where(id => !kept.Contains(id))
|
||||
.ToList();
|
||||
if (rail.Matches(plate, removedClamps))
|
||||
plate.SetClampRail(rail, removedClamps);
|
||||
}
|
||||
|
||||
private static bool SameInPlacementFrame(
|
||||
|
||||
Reference in new issue
Block a user