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));
}
}