From 029299ccf8fc44e709b640b228812d69ca577e4d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 25 Sep 2026 07:55:10 -0400 Subject: [PATCH] feat(engine): add NestJobResultBuilder for engine result assembly Engines assembled NestJobResult by hand - instance indices, fulfillment, stock usage, status and PlateCommitted progress - and Qwen38FlashNext got PlateIndex wrong (stock index instead of sheet order). The builder assigns plate and instance indices itself and rejects overproduction and exhausted stock, so engines only decide placements. Co-Authored-By: Codex Co-Authored-By: Claude Opus 5.5 --- .../Jobs/NestJobResultBuilderTests.cs | 129 ++++++++++++++++++ OpenNest.Engine/Jobs/NestJobResultBuilder.cs | 106 ++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 OpenNest.Engine.Tests/Jobs/NestJobResultBuilderTests.cs create mode 100644 OpenNest.Engine/Jobs/NestJobResultBuilder.cs diff --git a/OpenNest.Engine.Tests/Jobs/NestJobResultBuilderTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobResultBuilderTests.cs new file mode 100644 index 0000000..f39eab5 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/NestJobResultBuilderTests.cs @@ -0,0 +1,129 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +public class NestJobResultBuilderTests +{ + [Fact] + public void CommitsAssignIndicesAccountingAndCumulativeProgress() + { + var job = Job(); + var progress = new Reports(); + var builder = new NestJobResultBuilder(job, progress); + var finite = job.Plates[0]; + var unlimited = job.Plates[1]; + + Assert.False(builder.IsComplete); + Assert.Equal(0, builder.Placed("a")); + Assert.Equal(0, builder.SheetsUsed(unlimited)); + Assert.Equal(0, builder.AddSheet(unlimited, new[] { ("b", 1.0, 2.0, 0.5), ("a", 3.0, 4.0, 1.0) })); + var snapshot = builder.Build(NestJobStopReason.NoPlacementFound); + Assert.Equal(1, builder.AddSheet(finite, new[] { ("a", 5.0, 6.0, 1.5), ("b", 7.0, 8.0, 2.0) })); + Assert.Equal(2, builder.AddSheet(unlimited, new[] { ("a", 9.0, 10.0, 2.5) })); + var result = builder.Build(NestJobStopReason.StockExhausted); + + Assert.True(builder.IsComplete); + Assert.Equal(3, builder.Placed("a")); + Assert.Equal(2, builder.Placed("b")); + Assert.Equal(2, builder.SheetsUsed(unlimited)); + Assert.Equal(1, builder.SheetsUsed(finite)); + Assert.Equal(new[] { 0, 1, 2 }, result.Plates.Select(p => p.PlateIndex)); + Assert.Equal(new[] { "unlimited", "finite", "unlimited" }, result.Plates.Select(p => p.StockId)); + var poses = result.Plates.SelectMany(p => p.Placements).ToArray(); + Assert.Equal(new[] { 0, 1, 2 }, poses.Where(p => p.PartId == "a").Select(p => p.InstanceIndex)); + Assert.Equal(new[] { 0, 1 }, poses.Where(p => p.PartId == "b").Select(p => p.InstanceIndex)); + Assert.Equal(new NestJobPlacement("b", 0, 1, 2, 0.5), poses[0]); + Assert.Equal(new NestJobPlacement("a", 2, 9, 10, 2.5), poses[^1]); + Assert.Equal(new[] { new PartFulfillment("a", 3, 3, 0), new PartFulfillment("b", 2, 2, 0) }, result.Fulfillment); + Assert.All(result.Fulfillment, f => Assert.Equal(f.Requested, f.Placed + f.Unplaced)); + Assert.Equal(new[] { new StockUsage("finite", 1, 1), new StockUsage("unlimited", 2, null) }, result.StockUsage); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(NestJobStopReason.Completed, result.StopReason); + Assert.Equal(new[] + { + new NestJobProgress(NestJobStage.PlateCommitted, "unlimited", 0, 1, 2), + new NestJobProgress(NestJobStage.PlateCommitted, "finite", 1, 2, 4), + new NestJobProgress(NestJobStage.PlateCommitted, "unlimited", 2, 3, 5), + }, progress.Values); + Assert.Single(snapshot.Plates); + Assert.Equal(new PartFulfillment("a", 3, 1, 2), snapshot.Fulfillment[0]); + Assert.Equal(new StockUsage("unlimited", 1, null), snapshot.StockUsage[1]); + } + + [Theory] + [InlineData(NestJobStopReason.StockExhausted)] + [InlineData(NestJobStopReason.NoPlacementFound)] + [InlineData(NestJobStopReason.PlateLimitReached)] + public void IncompleteBuildPreservesStopReason(NestJobStopReason reason) + { + var result = new NestJobResultBuilder(Job()).Build(reason); + + Assert.Equal(NestJobStatus.Incomplete, result.Status); + Assert.Equal(reason, result.StopReason); + Assert.Empty(result.Plates); + Assert.Equal(new PartFulfillment("a", 3, 0, 3), result.Fulfillment[0]); + Assert.Equal(new StockUsage("finite", 0, 2), result.StockUsage[0]); + Assert.Equal(new StockUsage("unlimited", 0, null), result.StockUsage[1]); + } + + [Fact] + public void OverproductionRejectsWholeSheetWithoutConsumingIndicesOrReportingProgress() + { + var job = Job(); + var progress = new Reports(); + var builder = new NestJobResultBuilder(job, progress); + var stock = job.Plates[0]; + builder.AddSheet(stock, new[] { ("b", 0.0, 0.0, 0.0) }); + + Assert.Throws(() => builder.AddSheet(stock, + new[] { ("a", 0.0, 0.0, 0.0), ("b", 0.0, 0.0, 0.0), ("b", 0.0, 0.0, 0.0) })); + + Assert.Equal(0, builder.Placed("a")); + Assert.Equal(1, builder.Placed("b")); + Assert.Equal(1, builder.SheetsUsed(stock)); + Assert.Single(progress.Values); + Assert.Equal(1, builder.AddSheet(stock, new[] { ("b", 0.0, 0.0, 0.0) })); + var result = builder.Build(NestJobStopReason.StockExhausted); + Assert.Equal(1, Assert.Single(result.Plates[1].Placements).InstanceIndex); + Assert.Equal(new StockUsage("finite", 2, 0), result.StockUsage[0]); + Assert.Throws(() => builder.AddSheet(stock, new[] { ("a", 0.0, 0.0, 0.0) })); + } + + [Fact] + public void UnknownPartOrForeignStockCannotChangeAccounting() + { + var job = Job(); + var builder = new NestJobResultBuilder(job); + Assert.Throws(() => builder.AddSheet(job.Plates[0], new[] { ("unknown", 0.0, 0.0, 0.0) })); + Assert.Throws(() => builder.AddSheet(new NestPlateStock("finite", new Size(10, 10)), + new[] { ("a", 0.0, 0.0, 0.0) })); + Assert.Empty(builder.Build(NestJobStopReason.NoPlacementFound).Plates); + Assert.Equal(0, builder.SheetsUsed(job.Plates[0])); + } + + [Fact] + public void EmptyDemandIsComplete() + { + var builder = new NestJobResultBuilder(new NestJob(Array.Empty(), Array.Empty())); + Assert.True(builder.IsComplete); + var result = builder.Build(NestJobStopReason.NoPlacementFound); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(NestJobStopReason.Completed, result.StopReason); + } + + private static NestJob Job() + { + var geometry = PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(1, 1)); + return new NestJob( + new[] { new NestJobPart("a", geometry, 3), new NestJobPart("b", geometry, 2) }, + new[] { new NestPlateStock("finite", new Size(10, 10), 2), new NestPlateStock("unlimited", new Size(10, 10)) } + ); + } + + private sealed class Reports : IProgress + { + public List Values { get; } = new(); + public void Report(NestJobProgress value) => Values.Add(value); + } +} diff --git a/OpenNest.Engine/Jobs/NestJobResultBuilder.cs b/OpenNest.Engine/Jobs/NestJobResultBuilder.cs new file mode 100644 index 0000000..da5e1b9 --- /dev/null +++ b/OpenNest.Engine/Jobs/NestJobResultBuilder.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest.Engine.Jobs; + +/// Assembles committed sheets and quantity accounting without choosing or validating poses. +public sealed class NestJobResultBuilder +{ + private readonly NestJob job; + private readonly IProgress progress; + private readonly Dictionary parts; + private readonly Dictionary stocks; + private readonly Dictionary placed; + private readonly Dictionary used; + private readonly List sheets = new(); + private int committedParts; + + /// Creates an empty result builder for the job and optional progress reporter. + public NestJobResultBuilder(NestJob job, IProgress progress = null) + { + ArgumentNullException.ThrowIfNull(job); + this.job = job; + this.progress = progress; + parts = job.Parts.ToDictionary(p => p.Id, StringComparer.Ordinal); + stocks = job.Plates.ToDictionary(s => s.Id, StringComparer.Ordinal); + placed = job.Parts.ToDictionary(p => p.Id, _ => 0, StringComparer.Ordinal); + used = job.Plates.ToDictionary(s => s.Id, _ => 0, StringComparer.Ordinal); + } + + /// + /// Commits a sheet in call order, assigning zero-based instance indices per part across sheets. + /// Reports PlateCommitted and returns the new zero-based PlateIndex. Stock must belong to the job. + /// Unknown parts, overproduction, and exhausted stock are rejected before accounting changes. + /// + public int AddSheet( + NestPlateStock stock, + IEnumerable<(string PartId, double X, double Y, double Rotation)> placements + ) + { + ArgumentNullException.ThrowIfNull(placements); + RequireStock(stock); + if (stock.Quantity.HasValue && used[stock.Id] >= stock.Quantity.Value) + throw new InvalidOperationException("Stock is exhausted."); + + var counts = new Dictionary(placed, StringComparer.Ordinal); + var poses = new List(); + foreach (var pose in placements) + { + if (pose.PartId == null || !parts.TryGetValue(pose.PartId, out var part)) + throw new ArgumentException("Unknown part ID.", nameof(placements)); + var index = counts[pose.PartId]; + if (index >= part.Quantity) + throw new InvalidOperationException("Placement exceeds the requested quantity."); + poses.Add(new NestJobPlacement(pose.PartId, index, pose.X, pose.Y, pose.Rotation)); + counts[pose.PartId]++; + } + + var plateIndex = sheets.Count; + sheets.Add(new NestJobPlateResult(plateIndex, stock, poses)); + foreach (var count in counts) + placed[count.Key] = count.Value; + used[stock.Id]++; + committedParts += poses.Count; + progress?.Report(new NestJobProgress( + NestJobStage.PlateCommitted, stock.Id, plateIndex, sheets.Count, committedParts)); + return plateIndex; + } + + /// Number of physical sheets committed from this job's stock. + public int SheetsUsed(NestPlateStock stock) + { + RequireStock(stock); + return used[stock.Id]; + } + + /// Number of committed instances of the requirement ID. + public int Placed(string partId) => placed[partId]; + + /// True when every requested instance has been committed. + public bool IsComplete => job.Parts.All(p => placed[p.Id] == p.Quantity); + + /// + /// Returns a detached accounting snapshot in commit/input order. Complete jobs use Completed; + /// otherwise the result is Incomplete and uses the supplied stop reason. + /// + public NestJobResult Build(NestJobStopReason stopReason) + { + var complete = IsComplete; + return new NestJobResult( + complete ? NestJobStatus.Complete : NestJobStatus.Incomplete, + complete ? NestJobStopReason.Completed : stopReason, + sheets, + job.Parts.Select(p => new PartFulfillment( + p.Id, p.Quantity, placed[p.Id], p.Quantity - placed[p.Id])), + job.Plates.Select(s => new StockUsage(s.Id, used[s.Id], s.Quantity - used[s.Id])) + ); + } + + private void RequireStock(NestPlateStock stock) + { + ArgumentNullException.ThrowIfNull(stock); + if (!stocks.TryGetValue(stock.Id, out var owned) || !ReferenceEquals(stock, owned)) + throw new ArgumentException("Stock must belong to the job.", nameof(stock)); + } +}