diff --git a/OpenNest.Engine.Tests/Jobs/NestPipelineTests.cs b/OpenNest.Engine.Tests/Jobs/NestPipelineTests.cs new file mode 100644 index 0000000..30774cf --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/NestPipelineTests.cs @@ -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 solve) : INestingEngine + { + public NestJobResult Solve( + NestJob job, + IProgress? 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(() => + 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(() => + NestPipeline.Run(Request("Default", Item("bracket", 1)), null, cts.Token) + ); + } +} diff --git a/OpenNest.Engine/Jobs/Adapters/NestResultBinder.cs b/OpenNest.Engine/Jobs/Adapters/NestResultBinder.cs new file mode 100644 index 0000000..0187d73 --- /dev/null +++ b/OpenNest.Engine/Jobs/Adapters/NestResultBinder.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Jobs.Adapters; + +/// +/// Binds one result sheet's poses to the caller's own drawings. The pose semantics match +/// : 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. +/// +public static class NestResultBinder +{ + public static IReadOnlyList Bind( + NestJobPlateResult sheet, + IReadOnlyDictionary drawingsByPartId + ) + { + ArgumentNullException.ThrowIfNull(sheet); + ArgumentNullException.ThrowIfNull(drawingsByPartId); + var parts = new List(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; + } +} diff --git a/OpenNest.Engine/Jobs/NestLayoutCheck.cs b/OpenNest.Engine/Jobs/NestLayoutCheck.cs index 64f5d66..b0ca987 100644 --- a/OpenNest.Engine/Jobs/NestLayoutCheck.cs +++ b/OpenNest.Engine/Jobs/NestLayoutCheck.cs @@ -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. public static IReadOnlyList Violations(NestJob job, NestJobResult result) { + ArgumentNullException.ThrowIfNull(job); + return Violations(job, result, job.Parts.ToDictionary(p => p.Id, p => p.Id)); + } + + /// Same checks as , with messages + /// naming each requirement by (requirement ID -> name). + public static IReadOnlyList Violations( + NestJob job, + NestJobResult result, + IReadOnlyDictionary 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; } diff --git a/OpenNest.Engine/Jobs/NestPipeline.cs b/OpenNest.Engine/Jobs/NestPipeline.cs new file mode 100644 index 0000000..84facaf --- /dev/null +++ b/OpenNest.Engine/Jobs/NestPipeline.cs @@ -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; + +/// +/// 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. +/// +public sealed record NestPipelineRequest( + string EngineName, + IReadOnlyList Items, + IReadOnlyList Stock, + NestJobOptions Options = null +); + +/// A proposed sheet: new parts bound by reference to the caller's drawings. +public sealed record ProposedPlate(int PlateIndex, NestPlateStock Stock, IReadOnlyList Parts); + +/// +/// 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. +/// +public sealed class NestPipelineResult +{ + internal NestPipelineResult( + string engineName, + NestJob job, + NestJobResult raw, + IReadOnlyList plates, + IReadOnlyList 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 Plates { get; } + public IReadOnlyList 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; } +} + +/// +/// 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. +/// +public static class NestPipeline +{ + /// Resolves through + /// ; unknown names throw . + /// Engine cancellation propagates unchanged. + public static NestPipelineResult Run( + NestPipelineRequest request, + IProgress progress = null, + CancellationToken token = default + ) + { + ArgumentNullException.ThrowIfNull(request); + var engine = NestingEngineRegistry.Create(request.EngineName); + return Run(engine, request.EngineName, request, progress, token); + } + + /// Runs a caller-supplied engine instance through the same validation and binding. + public static NestPipelineResult Run( + INestingEngine engine, + string engineName, + NestPipelineRequest request, + IProgress 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(StringComparer.Ordinal); + var parts = new List(); + 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 + ); + } + + /// 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. + private static IReadOnlyList Validate( + NestJob job, + NestJobResult raw, + IReadOnlyDictionary drawingsByPartId + ) + { + var violations = new List(); + 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; + } +}