Add NestPipeline: one validated engine path for all front ends
build NestJob -> resolve engine by name -> Solve -> independent NestLayoutCheck validation -> bind placements to caller drawings. The pipeline never commits to plates and never mutates caller items; validation failures are returned as messages naming real drawings (unknown-requirement placements are reported, not dropped). Console, MCP, API and desktop Auto Nest will adopt this path in later phases. - NestPipeline.Run(request): registry-resolved engine, unknown names list the registered engines; cancellation propagates untouched. - NestPipeline.Run(engine, ...): stub/plug-in engines take the same path. - NestResultBinder: pose semantics identical to NestResultMaterializer. - NestLayoutCheck: Violations overload with per-requirement display names. Tests: overlap stub -> violations w/ drawing names, no throw; ghost placement -> violation + excluded from further checks; unknown engine -> NotSupportedException listing engines; cancelled token -> no result; happy path -> bound by reference, caller quantity untouched.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs;
|
||||
|
||||
public class NestPipelineTests
|
||||
{
|
||||
private static NestPlateStock Sheet() =>
|
||||
new("sheet", new Size(48, 96), quantity: null, partSpacing: 0.25);
|
||||
|
||||
private static NestItem Item(string name, int quantity) =>
|
||||
new()
|
||||
{
|
||||
Drawing = new Drawing(name, TestDrawingFactory.Rectangle()),
|
||||
Quantity = quantity,
|
||||
};
|
||||
|
||||
private static NestPipelineRequest Request(string engine, params NestItem[] items) =>
|
||||
new(engine, items, new[] { Sheet() });
|
||||
|
||||
private static NestJobResult OnePlate(NestJob job, params NestJobPlacement[] placements) =>
|
||||
new(
|
||||
NestJobStatus.Complete,
|
||||
NestJobStopReason.Completed,
|
||||
new[] { new NestJobPlateResult(0, job.Plates[0], placements) },
|
||||
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, p.Quantity, 0)),
|
||||
new[] { new StockUsage(job.Plates[0].Id, 1, null) }
|
||||
);
|
||||
|
||||
private sealed class StubEngine(Func<NestJob, NestJobResult> solve) : INestingEngine
|
||||
{
|
||||
public NestJobResult Solve(
|
||||
NestJob job,
|
||||
IProgress<NestJobProgress>? progress = null,
|
||||
CancellationToken token = default
|
||||
) => solve(job);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisteredEngineResultIsValidatedAndBoundToCallerDrawings()
|
||||
{
|
||||
var item = Item("bracket", 10);
|
||||
var codes = item.Drawing.Program.Codes.Count;
|
||||
|
||||
var result = NestPipeline.Run(Request("Default", item));
|
||||
|
||||
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
||||
Assert.Equal(NestJobStatus.Complete, result.Status);
|
||||
var parts = result.Plates.SelectMany(p => p.Parts).ToList();
|
||||
Assert.Equal(10, parts.Count);
|
||||
Assert.All(parts, part => Assert.Same(item.Drawing, part.BaseDrawing));
|
||||
Assert.All(result.Plates, plate => Assert.Same(result.Job.Plates[0], plate.Stock));
|
||||
Assert.Equal(10, item.Quantity);
|
||||
Assert.Equal(codes, item.Drawing.Program.Codes.Count);
|
||||
Assert.Equal(0, item.Drawing.Quantity.Nested);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OverlappingEngineOutputIsReportedByDrawingNameWithoutThrowing()
|
||||
{
|
||||
var engine = new StubEngine(job =>
|
||||
OnePlate(
|
||||
job,
|
||||
new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0),
|
||||
new NestJobPlacement(job.Parts[0].Id, 1, 1, 1, 0)
|
||||
)
|
||||
);
|
||||
|
||||
var result = NestPipeline.Run(engine, "Overlapper", Request("Overlapper", Item("bracket", 2)));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Violations, v => v.Contains("bracket") && v.Contains("spacing"));
|
||||
Assert.Equal(2, result.Plates.Single().Parts.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlacementForUnknownRequirementIsAViolationNotSilentlyDropped()
|
||||
{
|
||||
var engine = new StubEngine(job =>
|
||||
OnePlate(
|
||||
job,
|
||||
new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0),
|
||||
new NestJobPlacement("ghost", 0, 30, 1, 0)
|
||||
)
|
||||
);
|
||||
|
||||
var result = NestPipeline.Run(engine, "Ghost", Request("Ghost", Item("bracket", 1)));
|
||||
|
||||
Assert.False(result.IsValid);
|
||||
Assert.Contains(result.Violations, v => v.Contains("ghost"));
|
||||
Assert.Single(result.Plates.Single().Parts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownEngineNameListsRegisteredEngines()
|
||||
{
|
||||
var error = Assert.Throws<NotSupportedException>(() =>
|
||||
NestPipeline.Run(Request("Mystery Engine", Item("bracket", 1)))
|
||||
);
|
||||
|
||||
Assert.Contains("Default", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CancellationPropagatesWithoutAResult()
|
||||
{
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
Assert.ThrowsAny<OperationCanceledException>(() =>
|
||||
NestPipeline.Run(Request("Default", Item("bracket", 1)), null, cts.Token)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using OpenNest.Geometry;
|
||||
|
||||
namespace OpenNest.Engine.Jobs.Adapters;
|
||||
|
||||
/// <summary>
|
||||
/// Binds one result sheet's poses to the caller's own drawings. The pose semantics match
|
||||
/// <see cref="NestResultMaterializer"/>: rotate about the snapshot origin, then translate.
|
||||
/// Placements whose requirement ID is not in the map are skipped here; the pipeline reports
|
||||
/// them as violations instead of dropping them silently.
|
||||
/// </summary>
|
||||
public static class NestResultBinder
|
||||
{
|
||||
public static IReadOnlyList<Part> Bind(
|
||||
NestJobPlateResult sheet,
|
||||
IReadOnlyDictionary<string, Drawing> drawingsByPartId
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(sheet);
|
||||
ArgumentNullException.ThrowIfNull(drawingsByPartId);
|
||||
var parts = new List<Part>(sheet.Placements.Count);
|
||||
foreach (var pose in sheet.Placements)
|
||||
{
|
||||
if (!drawingsByPartId.TryGetValue(pose.PartId, out var drawing))
|
||||
continue;
|
||||
var part = new Part(drawing);
|
||||
part.Rotate(pose.Rotation);
|
||||
part.Location = new Vector(pose.X, pose.Y);
|
||||
part.UpdateBounds();
|
||||
parts.Add(part);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using OpenNest.Converters;
|
||||
@@ -25,12 +26,28 @@ public static class NestLayoutCheck
|
||||
/// not checked, matching the benchmark contract.</summary>
|
||||
public static IReadOnlyList<string> Violations(NestJob job, NestJobResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
return Violations(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id));
|
||||
}
|
||||
|
||||
/// <summary>Same checks as <see cref="Violations(NestJob, NestJobResult)"/>, with messages
|
||||
/// naming each requirement by <paramref name="displayNames"/> (requirement ID -> name).</summary>
|
||||
public static IReadOnlyList<string> Violations(
|
||||
NestJob job,
|
||||
NestJobResult result,
|
||||
IReadOnlyDictionary<string, string> displayNames
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(job);
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
ArgumentNullException.ThrowIfNull(displayNames);
|
||||
var materialized = NestResultMaterializer.Materialize(job, result);
|
||||
var requirements = job.Parts.ToDictionary(p => materialized.DrawingsByPartId[p.Id],
|
||||
p => (p.Id, p.Quantity));
|
||||
p => (Name: displayNames.GetValueOrDefault(p.Id, p.Id), p.Quantity));
|
||||
var runs = materialized.Nest.Plates.Select(p => (p, p.Parts.ToList())).ToList();
|
||||
var violations = Validate(runs, requirements);
|
||||
ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id), violations);
|
||||
ValidateAgainstJob(job, result, job.Parts.ToDictionary(p => p.Id,
|
||||
p => displayNames.GetValueOrDefault(p.Id, p.Id)), violations);
|
||||
return violations;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using OpenNest.Engine.Jobs.Adapters;
|
||||
|
||||
namespace OpenNest.Engine.Jobs;
|
||||
|
||||
/// <summary>
|
||||
/// One automatic-nesting request: the named engine, the caller's items (drawing, quantity,
|
||||
/// priority, rotation), the stock it may use, and optional job options. Items with a
|
||||
/// nonpositive quantity are skipped.
|
||||
/// </summary>
|
||||
public sealed record NestPipelineRequest(
|
||||
string EngineName,
|
||||
IReadOnlyList<NestItem> Items,
|
||||
IReadOnlyList<NestPlateStock> Stock,
|
||||
NestJobOptions Options = null
|
||||
);
|
||||
|
||||
/// <summary>A proposed sheet: new parts bound by reference to the caller's drawings.</summary>
|
||||
public sealed record ProposedPlate(int PlateIndex, NestPlateStock Stock, IReadOnlyList<Part> Parts);
|
||||
|
||||
/// <summary>
|
||||
/// The engine's raw result, its independent validation, and the proposed plates. Nothing
|
||||
/// has been committed; the caller decides what to do with an invalid result.
|
||||
/// </summary>
|
||||
public sealed class NestPipelineResult
|
||||
{
|
||||
internal NestPipelineResult(
|
||||
string engineName,
|
||||
NestJob job,
|
||||
NestJobResult raw,
|
||||
IReadOnlyList<ProposedPlate> plates,
|
||||
IReadOnlyList<string> violations,
|
||||
TimeSpan solveTime,
|
||||
TimeSpan validationTime
|
||||
)
|
||||
{
|
||||
EngineName = engineName;
|
||||
Job = job;
|
||||
Raw = raw;
|
||||
Plates = plates;
|
||||
Violations = violations;
|
||||
SolveTime = solveTime;
|
||||
ValidationTime = validationTime;
|
||||
}
|
||||
|
||||
public string EngineName { get; }
|
||||
public NestJob Job { get; }
|
||||
public NestJobResult Raw { get; }
|
||||
public IReadOnlyList<ProposedPlate> Plates { get; }
|
||||
public IReadOnlyList<string> Violations { get; }
|
||||
public bool IsValid => Violations.Count == 0;
|
||||
public NestJobStatus Status => Raw.Status;
|
||||
public NestJobStopReason StopReason => Raw.StopReason;
|
||||
public TimeSpan SolveTime { get; }
|
||||
public TimeSpan ValidationTime { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The single automatic-nesting path shared by every front end: build the job, solve it
|
||||
/// with the named engine, validate the result with the benchmark's rules, then bind
|
||||
/// placements to the caller's drawings. The pipeline never mutates caller items, drawings,
|
||||
/// or plates, and it does not care which engine is selected.
|
||||
/// </summary>
|
||||
public static class NestPipeline
|
||||
{
|
||||
/// <summary>Resolves <see cref="NestPipelineRequest.EngineName"/> through
|
||||
/// <see cref="NestingEngineRegistry"/>; unknown names throw <see cref="NotSupportedException"/>.
|
||||
/// Engine cancellation propagates unchanged.</summary>
|
||||
public static NestPipelineResult Run(
|
||||
NestPipelineRequest request,
|
||||
IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var engine = NestingEngineRegistry.Create(request.EngineName);
|
||||
return Run(engine, request.EngineName, request, progress, token);
|
||||
}
|
||||
|
||||
/// <summary>Runs a caller-supplied engine instance through the same validation and binding.</summary>
|
||||
public static NestPipelineResult Run(
|
||||
INestingEngine engine,
|
||||
string engineName,
|
||||
NestPipelineRequest request,
|
||||
IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(engine);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(request.Items);
|
||||
ArgumentNullException.ThrowIfNull(request.Stock);
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
var drawingsByPartId = new Dictionary<string, Drawing>(StringComparer.Ordinal);
|
||||
var parts = new List<NestJobPart>();
|
||||
for (var i = 0; i < request.Items.Count; i++)
|
||||
{
|
||||
var item = request.Items[i];
|
||||
if (item == null || item.Quantity <= 0)
|
||||
continue;
|
||||
var partId = $"part-{i}";
|
||||
parts.Add(DrawingJobMapper.FromItem(partId, item));
|
||||
drawingsByPartId[partId] = item.Drawing;
|
||||
}
|
||||
|
||||
var job = new NestJob(parts, request.Stock, request.Options);
|
||||
|
||||
var clock = Stopwatch.StartNew();
|
||||
var raw =
|
||||
engine.Solve(job, progress, token)
|
||||
?? throw new InvalidOperationException($"Engine '{engineName}' returned no result.");
|
||||
var solveTime = clock.Elapsed;
|
||||
|
||||
clock.Restart();
|
||||
var violations = Validate(job, raw, drawingsByPartId);
|
||||
var validationTime = clock.Elapsed;
|
||||
|
||||
var plates = raw
|
||||
.Plates.Select(sheet => new ProposedPlate(
|
||||
sheet.PlateIndex,
|
||||
sheet.Stock,
|
||||
NestResultBinder.Bind(sheet, drawingsByPartId)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
return new NestPipelineResult(
|
||||
engineName,
|
||||
job,
|
||||
raw,
|
||||
plates,
|
||||
violations,
|
||||
solveTime,
|
||||
validationTime
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Benchmark validation, with messages naming the caller's drawings rather than
|
||||
/// internal requirement IDs. Placements for unknown requirements are reported and then
|
||||
/// excluded so the remaining layout is still checked.</summary>
|
||||
private static IReadOnlyList<string> Validate(
|
||||
NestJob job,
|
||||
NestJobResult raw,
|
||||
IReadOnlyDictionary<string, Drawing> drawingsByPartId
|
||||
)
|
||||
{
|
||||
var violations = new List<string>();
|
||||
var known = raw;
|
||||
var unknown = raw
|
||||
.Plates.SelectMany(sheet =>
|
||||
sheet.Placements.Where(p => !drawingsByPartId.ContainsKey(p.PartId))
|
||||
.Select(p => (sheet.PlateIndex, p.PartId))
|
||||
)
|
||||
.ToList();
|
||||
|
||||
if (unknown.Count > 0)
|
||||
{
|
||||
foreach (var group in unknown.GroupBy(u => (u.PlateIndex, u.PartId)))
|
||||
violations.Add(
|
||||
$"Plate {group.Key.PlateIndex} has {group.Count()} placement(s) for '{group.Key.PartId}', which is not part of this job"
|
||||
);
|
||||
|
||||
known = new NestJobResult(
|
||||
raw.Status,
|
||||
raw.StopReason,
|
||||
raw.Plates.Select(sheet => new NestJobPlateResult(
|
||||
sheet.PlateIndex,
|
||||
sheet.Stock,
|
||||
sheet.Placements.Where(p => drawingsByPartId.ContainsKey(p.PartId))
|
||||
)),
|
||||
raw.Fulfillment,
|
||||
raw.StockUsage
|
||||
);
|
||||
}
|
||||
|
||||
var names = drawingsByPartId.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => kv.Value.Name ?? kv.Key,
|
||||
StringComparer.Ordinal
|
||||
);
|
||||
violations.AddRange(NestLayoutCheck.Violations(job, known, names));
|
||||
return violations;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user