From 75c8adc76cfd283a7263b0bd0e44c16afc6c279d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 18 Sep 2026 02:09:06 -0400 Subject: [PATCH] feat(engine): select from mixed plate inventory --- .../Jobs/FiniteStockJobTests.cs | 5 +- .../Jobs/NestJobStockSelectionTests.cs | 119 ++++++++++++++++++ .../Jobs/NestJobCandidateComparer.cs | 50 ++++++++ OpenNest.Engine/Jobs/NestJobRunner.cs | 96 ++++++++------ OpenNest.Engine/Jobs/NestJobValidator.cs | 3 - 5 files changed, 228 insertions(+), 45 deletions(-) create mode 100644 OpenNest.Engine.Tests/Jobs/NestJobStockSelectionTests.cs create mode 100644 OpenNest.Engine/Jobs/NestJobCandidateComparer.cs diff --git a/OpenNest.Engine.Tests/Jobs/FiniteStockJobTests.cs b/OpenNest.Engine.Tests/Jobs/FiniteStockJobTests.cs index 6b7147a..8dd221b 100644 --- a/OpenNest.Engine.Tests/Jobs/FiniteStockJobTests.cs +++ b/OpenNest.Engine.Tests/Jobs/FiniteStockJobTests.cs @@ -105,11 +105,12 @@ public class FiniteStockJobTests } [Fact] - public void MixedStockIsNotSilentlyIgnored() + public void MixedStockCanBeEvaluated() { var job = Job(); var mixed = new NestJob(job.Parts, job.Plates.Concat(new[] { new NestPlateStock("other", new Size(10, 20), 2) })); - Assert.Throws(() => new NestJobRunner(_ => new Nester(One)).Solve(mixed)); + var result = new NestJobRunner(_ => new Nester(One)).Solve(mixed); + Assert.Equal(NestJobStatus.Complete, result.Status); } [Theory] diff --git a/OpenNest.Engine.Tests/Jobs/NestJobStockSelectionTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobStockSelectionTests.cs new file mode 100644 index 0000000..9038f8d --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/NestJobStockSelectionTests.cs @@ -0,0 +1,119 @@ +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +public class NestJobStockSelectionTests +{ + [Fact] + public void LaterFittingStockWinsWhenFirstStockCannotPlace() + { + var result = Solve(new[] { Part("p", 1) }, new[] { Stock("small", 10, 10, 1), Stock("large", 20, 20, 1) }, + request => request.Stock.Id == "large" ? Candidate(request, "p") : Empty()); + + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal("large", Assert.Single(result.Plates).StockId); + } + + [Fact] + public void ExhaustedLargeStockIsNotRecreatedWhileSmallerStockServesSmallParts() + { + var result = Solve(new[] { Part("large", 1, 0), Part("small", 2, 1) }, + new[] { Stock("large", 20, 20, 1), Stock("small", 10, 10, 2) }, request => request.Stock.Id switch + { + "large" when request.Parts.Any(part => part.Id == "large") => Candidate(request, "large"), + "small" when request.Parts.Any(part => part.Id == "small") => Candidate(request, "small"), + _ => Empty() + }); + + Assert.Equal(new[] { "large", "small", "small" }, result.Plates.Select(plate => plate.StockId)); + Assert.Collection(result.StockUsage, + usage => Assert.Equal(new StockUsage("large", 1, 0), usage), + usage => Assert.Equal(new StockUsage("small", 2, 0), usage)); + } + + [Fact] + public void EqualDimensionsWithDifferentStockIdsRemainIndependent() + { + var result = Solve(new[] { Part("p", 2) }, new[] { Stock("first", 10, 10, 1), Stock("second", 10, 10, 1) }, + request => Candidate(request, "p")); + + Assert.Equal(new[] { "first", "second" }, result.Plates.Select(plate => plate.StockId)); + Assert.Equal(new[] { new StockUsage("first", 1, 0), new StockUsage("second", 1, 0) }, result.StockUsage); + } + + [Fact] + public void LosingTrialsDoNotConsumeStockPartsOrDrawingCounters() + { + var calls = new List<(string Stock, int Quantity)>(); + var result = Solve(new[] { Part("p", 2) }, new[] { Stock("wide", 20, 20, 2), Stock("narrow", 10, 10, 2) }, request => + { + calls.Add((request.Stock.Id, request.Parts.Single().Quantity)); + return request.Stock.Id == "wide" ? Candidate(request, "p", 0, 100) : Candidate(request, "p", 0, 0); + }); + + Assert.Equal(new[] { "narrow", "narrow" }, result.Plates.Select(plate => plate.StockId)); + Assert.Equal(new[] { ("wide", 2), ("narrow", 2), ("wide", 1), ("narrow", 1) }, calls); + Assert.Equal(new StockUsage("wide", 0, 2), result.StockUsage[0]); + Assert.Equal(new StockUsage("narrow", 2, 0), result.StockUsage[1]); + Assert.Equal(new[] { 0, 1 }, result.Plates.SelectMany(plate => plate.Placements).Select(placement => placement.InstanceIndex)); + } + + [Fact] + public void CandidatePriorityAreaEnvelopeAndInputOrderAreComparedInDocumentedOrder() + { + var priority = Solve(new[] { Part("high", 1, 0), Part("low", 1, 1) }, new[] { Stock("a", 10, 10, 1), Stock("b", 10, 10, 1) }, + request => request.Stock.Id == "a" ? Candidate(request, "low") : Candidate(request, "high")); + var area = Solve(new[] { Part("p", 1) }, new[] { Stock("large", 20, 20, 1), Stock("small", 10, 10, 1) }, + request => Candidate(request, "p")); + var envelope = Solve(new[] { Part("p", 2) }, new[] { Stock("a", 10, 10, 1), Stock("b", 10, 10, 1) }, + request => request.Stock.Id == "a" ? CandidatePair("p", 0, 10) : CandidatePair("p", 0, 1)); + var inputOrder = Solve(new[] { Part("p", 1) }, new[] { Stock("first", 10, 10, 1), Stock("second", 10, 10, 1) }, + request => Candidate(request, "p")); + + Assert.Equal("b", priority.Plates[0].StockId); + Assert.Equal("small", area.Plates[0].StockId); + Assert.Equal("b", envelope.Plates[0].StockId); + Assert.Equal("first", inputOrder.Plates[0].StockId); + } + + [Fact] + public void UnlimitedStockStopsWhenDemandIsFulfilledAndPlateLimitLeavesLeftovers() + { + var unlimited = Solve(new[] { Part("p", 2) }, new[] { Stock("u", 10, 10, null) }, request => Candidate(request, "p")); + var limited = Solve(new[] { Part("p", 3) }, new[] { Stock("u", 10, 10, null) }, request => Candidate(request, "p"), new NestJobOptions(maxPlates: 2)); + + Assert.Equal(NestJobStopReason.Completed, unlimited.StopReason); + Assert.Equal(2, unlimited.Plates.Count); + Assert.Equal(NestJobStopReason.PlateLimitReached, limited.StopReason); + Assert.Equal(new PartFulfillment("p", 3, 2, 1), Assert.Single(limited.Fulfillment)); + } + + private static NestJobResult Solve(IEnumerable parts, IEnumerable stock, + Func place, NestJobOptions? options = null) => + new NestJobRunner(_ => new Nester(place)).Solve(new NestJob(parts, stock, options)); + + private static NestJobPart Part(string id, int quantity, int priority = 0) => + new(id, PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle()), quantity, priority); + + private static NestPlateStock Stock(string id, double width, double length, int? quantity) => + new(id, new Size(width, length), quantity); + + private static PlateCandidate Candidate(PlatePlacementRequest request, string id, double firstX = 0, double secondX = 0) + { + return new PlateCandidate(new[] { new NestJobPlacement(id, 0, firstX, 0, 0) }); + } + + private static PlateCandidate CandidatePair(string id, double first, double second) => new(new[] + { + new NestJobPlacement(id, 0, first, first, 0), + new NestJobPlacement(id, 1, second, second, 0) + }); + + private static PlateCandidate Empty() => new(Array.Empty()); + + private sealed class Nester(Func place) : IPlateNester + { + public PlateCandidate Place(PlatePlacementRequest request, IProgress? progress = null, + CancellationToken token = default) => place(request); + } +} diff --git a/OpenNest.Engine/Jobs/NestJobCandidateComparer.cs b/OpenNest.Engine/Jobs/NestJobCandidateComparer.cs new file mode 100644 index 0000000..025764e --- /dev/null +++ b/OpenNest.Engine/Jobs/NestJobCandidateComparer.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest; + +/// Ranks independent plate trials: priority fulfillment, sheet area, placement envelope, then input order. +public sealed class NestJobCandidateComparer +{ + private readonly IReadOnlyList parts; + + public NestJobCandidateComparer(IReadOnlyList parts) + { + this.parts = parts ?? throw new ArgumentNullException(nameof(parts)); + } + + /// Returns positive when the left trial is preferred. + public int Compare(PlateCandidate left, NestPlateStock leftStock, int leftIndex, + PlateCandidate right, NestPlateStock rightStock, int rightIndex) + { + var priorities = parts.Select(part => part.Priority).Distinct().OrderBy(priority => priority); + foreach (var priority in priorities) + { + var leftCount = Count(left, priority); + var rightCount = Count(right, priority); + if (leftCount != rightCount) return leftCount.CompareTo(rightCount); + } + + var area = Area(rightStock).CompareTo(Area(leftStock)); + if (area != 0) return area; + + var envelope = Envelope(right).CompareTo(Envelope(left)); + if (envelope != 0) return envelope; + + return rightIndex.CompareTo(leftIndex); + } + + private int Count(PlateCandidate candidate, int priority) => candidate.Placements.Count(placement => + parts.First(part => part.Id == placement.PartId).Priority == priority); + + private static double Area(NestPlateStock stock) => stock.Size.Width * stock.Size.Length; + + private static double Envelope(PlateCandidate candidate) + { + if (candidate.Placements.Count == 0) return 0; + var xs = candidate.Placements.Select(placement => placement.X); + var ys = candidate.Placements.Select(placement => placement.Y); + return (xs.Max() - xs.Min()) * (ys.Max() - ys.Min()); + } +} diff --git a/OpenNest.Engine/Jobs/NestJobRunner.cs b/OpenNest.Engine/Jobs/NestJobRunner.cs index 023c145..8f74b36 100644 --- a/OpenNest.Engine/Jobs/NestJobRunner.cs +++ b/OpenNest.Engine/Jobs/NestJobRunner.cs @@ -6,8 +6,8 @@ using System.Threading; namespace OpenNest; /// -/// Single-stock physical-sheet allocation. Candidate accounting is validated before commit; -/// full geometry, clearance, and rotation-policy validation is not implemented yet. +/// Physical-sheet allocation. Every available stock entry is tried independently and only the selected +/// candidate changes demand or inventory accounting. /// public sealed class NestJobRunner : INestingEngine { @@ -27,56 +27,72 @@ public sealed class NestJobRunner : INestingEngine token.ThrowIfCancellationRequested(); NestJobValidator.Validate(job); var plates = new List(); - var remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity, StringComparer.Ordinal); - var placed = job.Parts.ToDictionary(p => p.Id, _ => 0, StringComparer.Ordinal); + var remaining = job.Parts.ToDictionary(part => part.Id, part => part.Quantity, StringComparer.Ordinal); + var placed = job.Parts.ToDictionary(part => part.Id, _ => 0, StringComparer.Ordinal); + var used = job.Plates.ToDictionary(stock => stock.Id, _ => 0, StringComparer.Ordinal); + var comparer = new NestJobCandidateComparer(job.Parts); + var nester = job.Parts.Count == 0 ? null : plateNesterFactory(job.Options.PlacementStrategy) ?? + throw new NotSupportedException($"Unknown placement strategy: {job.Options.PlacementStrategy}."); var reason = NestJobStopReason.Completed; - if (job.Parts.Count != 0) + while (remaining.Values.Any(count => count > 0)) { - var nester = plateNesterFactory(job.Options.PlacementStrategy) ?? - throw new NotSupportedException($"Unknown placement strategy: {job.Options.PlacementStrategy}."); - var stock = job.Plates.SingleOrDefault(); - while (remaining.Values.Any(count => count > 0)) + token.ThrowIfCancellationRequested(); + if (job.Options.MaxPlates <= plates.Count) { - token.ThrowIfCancellationRequested(); - if (stock == null || stock.Quantity <= plates.Count) - { - reason = NestJobStopReason.StockExhausted; - break; - } - if (job.Options.MaxPlates <= plates.Count) - { - reason = NestJobStopReason.PlateLimitReached; - break; - } - var request = new PlatePlacementRequest(stock, job.Parts.Where(p => remaining[p.Id] > 0) - .Select(p => new NestJobPart(p.Id, p.Geometry, remaining[p.Id], p.Priority, p.Rotation))); + reason = NestJobStopReason.PlateLimitReached; + break; + } + + CandidateTrial winner = null; + var hasAvailableStock = false; + for (var index = 0; index < job.Plates.Count; index++) + { + var stock = job.Plates[index]; + if (stock.Quantity is int quantity && used[stock.Id] >= quantity) continue; + hasAvailableStock = true; + var request = new PlatePlacementRequest(stock, job.Parts.Where(part => remaining[part.Id] > 0) + .Select(part => new NestJobPart(part.Id, part.Geometry, remaining[part.Id], part.Priority, part.Rotation))); progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, plates.Count, plates.Count, placed.Values.Sum())); token.ThrowIfCancellationRequested(); - // Do not forward legacy/candidate progress as committed production. var candidate = nester.Place(request, token: token); token.ThrowIfCancellationRequested(); NestJobValidator.ValidateCandidate(candidate, remaining); - if (candidate.Placements.Count == 0) - { - reason = NestJobStopReason.NoPlacementFound; - break; - } - var committed = new List(); - foreach (var pose in candidate.Placements) - { - committed.Add(pose with { InstanceIndex = placed[pose.PartId]++ }); - remaining[pose.PartId]--; - } - plates.Add(new NestJobPlateResult(plates.Count, stock, committed)); - progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, stock.Id, - plates.Count - 1, plates.Count, placed.Values.Sum())); + var trial = new CandidateTrial(candidate, stock, index); + if (winner == null || comparer.Compare(trial.Candidate, trial.Stock, trial.StockIndex, + winner.Candidate, winner.Stock, winner.StockIndex) > 0) + winner = trial; } + + if (!hasAvailableStock) + { + reason = NestJobStopReason.StockExhausted; + break; + } + if (winner.Candidate.Placements.Count == 0) + { + reason = NestJobStopReason.NoPlacementFound; + break; + } + + var committed = new List(); + foreach (var pose in winner.Candidate.Placements) + { + committed.Add(pose with { InstanceIndex = placed[pose.PartId]++ }); + remaining[pose.PartId]--; + } + used[winner.Stock.Id]++; + plates.Add(new NestJobPlateResult(plates.Count, winner.Stock, committed)); + progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, winner.Stock.Id, + plates.Count - 1, plates.Count, placed.Values.Sum())); } + token.ThrowIfCancellationRequested(); return new NestJobResult(reason == NestJobStopReason.Completed ? NestJobStatus.Complete : NestJobStatus.Incomplete, - reason, plates, job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, placed[p.Id], remaining[p.Id])), - job.Plates.Select(stock => new StockUsage(stock.Id, plates.Count(p => p.StockId == stock.Id), - stock.Quantity - plates.Count(p => p.StockId == stock.Id)))); + reason, plates, job.Parts.Select(part => new PartFulfillment(part.Id, part.Quantity, placed[part.Id], remaining[part.Id])), + job.Plates.Select(stock => new StockUsage(stock.Id, used[stock.Id], + stock.Quantity is int quantity ? quantity - used[stock.Id] : null))); } + + private sealed record CandidateTrial(PlateCandidate Candidate, NestPlateStock Stock, int StockIndex); } diff --git a/OpenNest.Engine/Jobs/NestJobValidator.cs b/OpenNest.Engine/Jobs/NestJobValidator.cs index 7967d5f..a60a09a 100644 --- a/OpenNest.Engine/Jobs/NestJobValidator.cs +++ b/OpenNest.Engine/Jobs/NestJobValidator.cs @@ -26,9 +26,6 @@ public static class NestJobValidator !double.IsFinite(m.CenterX) || !double.IsFinite(m.CenterY))) throw new ArgumentException($"Geometry must contain finite motions: {part.Id}.", nameof(job)); } - // Empty jobs do not select or consume stock (including multiple unused stock entries). - if (job.Parts.Count != 0 && job.Plates.Count > 1) - throw new NotSupportedException("This slice supports one stock entry only; mixed-stock selection is not implemented."); } internal static void ValidateCandidate(PlateCandidate candidate, IReadOnlyDictionary remaining)