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 <noreply@openai.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<InvalidOperationException>(() => 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<InvalidOperationException>(() => 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<ArgumentException>(() => builder.AddSheet(job.Plates[0], new[] { ("unknown", 0.0, 0.0, 0.0) }));
|
||||||
|
Assert.Throws<ArgumentException>(() => 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<NestJobPart>(), Array.Empty<NestPlateStock>()));
|
||||||
|
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<NestJobProgress>
|
||||||
|
{
|
||||||
|
public List<NestJobProgress> Values { get; } = new();
|
||||||
|
public void Report(NestJobProgress value) => Values.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace OpenNest.Engine.Jobs;
|
||||||
|
|
||||||
|
/// <summary>Assembles committed sheets and quantity accounting without choosing or validating poses.</summary>
|
||||||
|
public sealed class NestJobResultBuilder
|
||||||
|
{
|
||||||
|
private readonly NestJob job;
|
||||||
|
private readonly IProgress<NestJobProgress> progress;
|
||||||
|
private readonly Dictionary<string, NestJobPart> parts;
|
||||||
|
private readonly Dictionary<string, NestPlateStock> stocks;
|
||||||
|
private readonly Dictionary<string, int> placed;
|
||||||
|
private readonly Dictionary<string, int> used;
|
||||||
|
private readonly List<NestJobPlateResult> sheets = new();
|
||||||
|
private int committedParts;
|
||||||
|
|
||||||
|
/// <summary>Creates an empty result builder for the job and optional progress reporter.</summary>
|
||||||
|
public NestJobResultBuilder(NestJob job, IProgress<NestJobProgress> 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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<string, int>(placed, StringComparer.Ordinal);
|
||||||
|
var poses = new List<NestJobPlacement>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of physical sheets committed from this job's stock.</summary>
|
||||||
|
public int SheetsUsed(NestPlateStock stock)
|
||||||
|
{
|
||||||
|
RequireStock(stock);
|
||||||
|
return used[stock.Id];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Number of committed instances of the requirement ID.</summary>
|
||||||
|
public int Placed(string partId) => placed[partId];
|
||||||
|
|
||||||
|
/// <summary>True when every requested instance has been committed.</summary>
|
||||||
|
public bool IsComplete => job.Parts.All(p => placed[p.Id] == p.Quantity);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a detached accounting snapshot in commit/input order. Complete jobs use Completed;
|
||||||
|
/// otherwise the result is Incomplete and uses the supplied stop reason.
|
||||||
|
/// </summary>
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user