From 0963b051be8a2c641ddb63f102588d1d75575374 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 17 Sep 2026 13:55:11 -0400 Subject: [PATCH] feat(engine): execute inventory-bounded multi-plate jobs --- .../Jobs/FiniteStockJobTests.cs | 182 ++++++++++++++++++ OpenNest.Engine.Tests/Jobs/JobAdapterTests.cs | 158 +++++++++++++++ .../Jobs/NestJobRunnerTests.cs | 7 +- .../Jobs/Adapters/DrawingJobMapper.cs | 77 ++++++++ .../Jobs/Adapters/LegacyPlateNesterAdapter.cs | 61 ++++++ .../Jobs/Adapters/NestResultMaterializer.cs | 51 +++++ OpenNest.Engine/Jobs/NestJobRunner.cs | 64 +++++- OpenNest.Engine/Jobs/NestJobValidator.cs | 52 +++++ README.md | 16 +- 9 files changed, 659 insertions(+), 9 deletions(-) create mode 100644 OpenNest.Engine.Tests/Jobs/FiniteStockJobTests.cs create mode 100644 OpenNest.Engine.Tests/Jobs/JobAdapterTests.cs create mode 100644 OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs create mode 100644 OpenNest.Engine/Jobs/Adapters/LegacyPlateNesterAdapter.cs create mode 100644 OpenNest.Engine/Jobs/Adapters/NestResultMaterializer.cs create mode 100644 OpenNest.Engine/Jobs/NestJobValidator.cs diff --git a/OpenNest.Engine.Tests/Jobs/FiniteStockJobTests.cs b/OpenNest.Engine.Tests/Jobs/FiniteStockJobTests.cs new file mode 100644 index 0000000..6b7147a --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/FiniteStockJobTests.cs @@ -0,0 +1,182 @@ +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +public class FiniteStockJobTests +{ + internal static NestJob Job(int? stock = 3, NestJobOptions? options = null) => new( + new[] { new NestJobPart("p", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle()), 3) }, + new[] { new NestPlateStock("s", new Size(100, 200), stock) }, options); + + internal sealed class Nester(Func place) : IPlateNester + { + public int Calls { get; private set; } + public PlateCandidate Place(PlatePlacementRequest request, IProgress? progress = null, + CancellationToken token = default) { Calls++; return place(request); } + } + + internal static PlateCandidate One(PlatePlacementRequest request) => new(new[] + { new NestJobPlacement(request.Parts[0].Id, 99, 1, 2, 0) }); + + [Theory] + [InlineData(3, 3, 0, NestJobStatus.Complete, NestJobStopReason.Completed)] + [InlineData(2, 2, 1, NestJobStatus.Incomplete, NestJobStopReason.StockExhausted)] + [InlineData(0, 0, 3, NestJobStatus.Incomplete, NestJobStopReason.StockExhausted)] + public void DemandAndPhysicalStockAreAccountedFromPlacements(int stock, int placed, int left, + NestJobStatus status, NestJobStopReason reason) + { + var requests = new List(); + var nester = new Nester(r => { requests.Add(r.Parts[0].Quantity); return One(r); }); + var job = Job(stock); + var result = new NestJobRunner(_ => nester).Solve(job); + Assert.Equal(status, result.Status); + Assert.Equal(reason, result.StopReason); + Assert.Equal(placed, result.Plates.Count); + Assert.All(result.Plates, p => Assert.Single(p.Placements)); + Assert.Equal(Enumerable.Range(0, placed), result.Plates.SelectMany(p => p.Placements).Select(p => p.InstanceIndex)); + Assert.Equal(Enumerable.Range(0, placed).Select(i => 3 - i), requests); + Assert.Equal(new PartFulfillment("p", 3, placed, left), Assert.Single(result.Fulfillment)); + Assert.Equal(new StockUsage("s", placed, stock - placed), Assert.Single(result.StockUsage)); + Assert.Equal(3, job.Parts[0].Quantity); + Assert.Equal(stock, job.Plates[0].Quantity); + } + + [Theory] + [InlineData(null)] + [InlineData(3)] + public void NoPlacementStopsWithoutConsumingStock(int? stock) + { + var nester = new Nester(_ => new PlateCandidate(Array.Empty())); + var result = new NestJobRunner(_ => nester).Solve(Job(stock)); + Assert.Equal(1, nester.Calls); + Assert.Empty(result.Plates); + Assert.Equal(NestJobStatus.Incomplete, result.Status); + Assert.Equal(NestJobStopReason.NoPlacementFound, result.StopReason); + Assert.Equal(new StockUsage("s", 0, stock), Assert.Single(result.StockUsage)); + } + + [Fact] + public void PlateLimitStopsUnlimitedStock() + { + var result = new NestJobRunner(_ => new Nester(One)).Solve(Job(null, new NestJobOptions(maxPlates: 2))); + Assert.Equal(2, result.Plates.Count); + Assert.Equal(NestJobStopReason.PlateLimitReached, result.StopReason); + Assert.Equal(new StockUsage("s", 2, null), Assert.Single(result.StockUsage)); + } + + [Fact] + public void CancellationImmediatelyAfterEngineReturnThrowsWithoutCommit() + { + using var cts = new CancellationTokenSource(); + var commits = new List(); + var nester = new Nester(r => { cts.Cancel(); return One(r); }); + Assert.Throws(() => new NestJobRunner(_ => nester) + .Solve(Job(), new InlineProgress(commits.Add), cts.Token)); + Assert.DoesNotContain(commits, p => p.Stage == NestJobStage.PlateCommitted); + } + + [Fact] + public void InitialCancellationSkipsEngine() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + Assert.Throws(() => new NestJobRunner(_ => throw new Exception("called")) + .Solve(Job(), token: cts.Token)); + } + + [Theory] + [InlineData("unknown", 0, 0, 0, 1)] + [InlineData("p", double.NaN, 0, 0, 1)] + [InlineData("p", 0, double.PositiveInfinity, 0, 1)] + [InlineData("p", 0, 0, double.NaN, 1)] + [InlineData("p", 0, 0, 0, 4)] + public void InvalidCandidateThrows(string id, double x, double y, double rotation, int count) + { + var nester = new Nester(_ => new PlateCandidate(Enumerable.Range(0, count) + .Select(i => new NestJobPlacement(id, i, x, y, rotation)))); + Assert.Throws(() => new NestJobRunner(_ => nester).Solve(Job())); + } + + [Fact] + public void NullCandidateAndUnknownStrategyAreExplicitErrors() + { + Assert.Throws(() => new NestJobRunner(_ => new Nester(_ => null!)).Solve(Job())); + Assert.Throws(() => new NestJobRunner(_ => null!).Solve(Job(options: new NestJobOptions("missing")))); + } + + [Fact] + public void MixedStockIsNotSilentlyIgnored() + { + var job = Job(); + var mixed = new NestJob(job.Parts, job.Plates.Concat(new[] { new NestPlateStock("other", new Size(10, 20), 2) })); + Assert.Throws(() => new NestJobRunner(_ => new Nester(One)).Solve(mixed)); + } + + [Theory] + [InlineData(0, 20, 0, 1)] + [InlineData(10, double.NaN, 0, 1)] + [InlineData(10, 20, -1, 1)] + [InlineData(10, 20, double.PositiveInfinity, 1)] + [InlineData(10, 20, 0, 5)] + public void InvalidStockSettingsRejected(double width, double length, double spacing, int quadrant) + { + var job = new NestJob(Job().Parts, new[] { new NestPlateStock("s", new Size(width, length), 1, spacing, quadrant: quadrant) }); + Assert.Throws(() => new NestJobRunner(_ => new Nester(One)).Solve(job)); + } + + [Fact] + public void InvalidEdgesAndGeometryRejected() + { + var runner = new NestJobRunner(_ => new Nester(One)); + foreach (var edges in new[] { new Spacing(-1, 0, 0, 0), new Spacing(0, double.NaN, 0, 0), new Spacing(1000, 1000, 1000, 1000) }) + Assert.Throws(() => runner.Solve(new NestJob(Job().Parts, + new[] { new NestPlateStock("s", new Size(100, 200), edgeSpacing: edges) }))); + var program = TestDrawingFactory.Rectangle(); + program.LineTo(double.NaN, 0); + Assert.Throws(() => runner.Solve(new NestJob(new[] + { new NestJobPart("p", PartGeometrySnapshot.FromProgram(program), 1) }, Job().Plates))); + } + + [Fact] + public void InvalidContractInputsAreRejected() + { + var job = Job(); + Assert.Throws(() => new NestJobRunner(_ => new Nester(One)).Solve(null!)); + Assert.Throws(() => new NestJob(new NestJobPart[] { null! }, job.Plates)); + Assert.Throws(() => new NestJob(job.Parts.Concat(job.Parts), job.Plates)); + Assert.Throws(() => new NestJob(job.Parts, job.Plates.Concat(job.Plates))); + Assert.Throws(() => new NestJobPart("p", job.Parts[0].Geometry, 0)); + Assert.Throws(() => new NestPlateStock("s", new Size(1, 1), -1)); + Assert.Throws(() => new NestJobOptions(maxPlates: 0)); + } + + [Fact] + public void RunnerFactoriesAreInstanceScopedAndReceiveExactStrategyKeys() + { + var keys = new List(); + var first = new NestJobRunner(key => { keys.Add(key); return new Nester(One); }); + var second = new NestJobRunner(key => { keys.Add(key); return new Nester(_ => new PlateCandidate(Array.Empty())); }); + Assert.Equal(NestJobStatus.Complete, first.Solve(Job(options: new NestJobOptions("custom-A"))).Status); + Assert.Equal(NestJobStopReason.NoPlacementFound, second.Solve(Job(options: new NestJobOptions("custom-B"))).StopReason); + Assert.Equal(NestJobStatus.Complete, first.Solve(Job(options: new NestJobOptions("custom-A"))).Status); + Assert.Equal(new[] { "custom-A", "custom-B", "custom-A" }, keys); + } + + [Fact] + public void CancellationAfterAnEarlierCommitStillThrowsRatherThanReturningPartialResult() + { + using var cts = new CancellationTokenSource(); + var calls = 0; + var commits = new List(); + var nester = new Nester(r => { if (++calls == 2) cts.Cancel(); return One(r); }); + Assert.Throws(() => new NestJobRunner(_ => nester) + .Solve(Job(), new InlineProgress(commits.Add), cts.Token)); + Assert.Equal(1, Assert.Single(commits.Where(p => p.Stage == NestJobStage.PlateCommitted)).CommittedParts); + Assert.Equal(2, calls); + } + + private sealed class InlineProgress(Action report) : IProgress + { + public void Report(NestJobProgress value) => report(value); + } +} diff --git a/OpenNest.Engine.Tests/Jobs/JobAdapterTests.cs b/OpenNest.Engine.Tests/Jobs/JobAdapterTests.cs new file mode 100644 index 0000000..2acf9ae --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/JobAdapterTests.cs @@ -0,0 +1,158 @@ +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Engine.Tests.Jobs; + +public class JobAdapterTests +{ + [Fact] + public void LegacyMutationsCannotDoubleSubtractOrReachCallerObjects() + { + var drawing = new Drawing("same name", TestDrawingFactory.Rectangle()); + drawing.Quantity.Required = 9; + var item = new NestItem { Drawing = drawing, Quantity = 3, Priority = 7, StepAngle = 0 }; + var sourcePlate = new Plate(100, 200) { Quantity = 3, PartSpacing = 2 }; + var job = new NestJob(new[] { DrawingJobMapper.FromItem("requirement", item) }, + new[] { DrawingJobMapper.FromPlate("stock", sourcePlate, 3) }); + var quantities = new List(); + var adapter = new LegacyPlateNesterAdapter(p => new MutatingEngine(p, items => + { + var privateItem = Assert.Single(items); + quantities.Add(privateItem.Quantity); + Assert.NotSame(drawing, privateItem.Drawing); + Assert.Equal(0, privateItem.StepAngle); + Assert.Equal(7, privateItem.Priority); + var part = new Part(privateItem.Drawing); + privateItem.Quantity = 0; + privateItem.Drawing.Quantity.Required = 0; + return new List { part }; + })); + var result = new NestJobRunner(_ => adapter).Solve(job); + var materialized = NestResultMaterializer.Materialize(job, result); + Assert.Equal(new[] { 3, 2, 1 }, quantities); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(3, materialized.Nest.Plates.Count); + Assert.All(materialized.Nest.Plates, p => { Assert.Equal(1, p.Quantity); Assert.Single(p.Parts); }); + var outputDrawing = materialized.DrawingsByPartId["requirement"]; + Assert.Equal(3, outputDrawing.Quantity.Required); + Assert.Equal(3, outputDrawing.Quantity.Nested); + Assert.All(materialized.Nest.Plates, p => Assert.Same(outputDrawing, p.Parts[0].BaseDrawing)); + Assert.NotSame(drawing, outputDrawing); + Assert.Equal(9, drawing.Quantity.Required); + Assert.Equal(0, drawing.Quantity.Nested); + Assert.Equal(3, item.Quantity); + Assert.Equal(3, sourcePlate.Quantity); + Assert.Empty(sourcePlate.Parts); + Assert.Equal(2, sourcePlate.PartSpacing); + Assert.Equal(PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle()).Motions, + PartGeometrySnapshot.FromProgram(drawing.Program).Motions); + } + + [Fact] + public void ReferenceIdentityNotNamesControlsLegacyPlacements() + { + var drawing = new Drawing("duplicate", TestDrawingFactory.Rectangle()); + var job = new NestJob(new[] { DrawingJobMapper.FromDrawing("a", drawing, 1), + DrawingJobMapper.FromDrawing("b", drawing, 1) }, FiniteStockJobTests.Job(1).Plates); + var adapter = new LegacyPlateNesterAdapter(p => new MutatingEngine(p, items => + { + Assert.NotSame(items[0].Drawing, items[1].Drawing); + foreach (var item in items) item.Drawing.Name = "identical"; + return items.Select(i => new Part(i.Drawing)).ToList(); + })); + var result = new NestJobRunner(_ => adapter).Solve(job); + Assert.Equal(new[] { "a", "b" }, result.Plates[0].Placements.Select(p => p.PartId)); + var output = NestResultMaterializer.Materialize(job, result); + Assert.NotSame(output.DrawingsByPartId["a"], output.DrawingsByPartId["b"]); + Assert.All(output.DrawingsByPartId.Values, d => Assert.Equal(1, d.Quantity.Nested)); + } + + [Fact] + public void UnknownPrivateDrawingIsRejectedEvenWithMatchingName() + { + var adapter = new LegacyPlateNesterAdapter(p => new MutatingEngine(p, items => new List + { new(new Drawing(items[0].Drawing.Name, TestDrawingFactory.Rectangle())) })); + Assert.Throws(() => new NestJobRunner(_ => adapter).Solve(FiniteStockJobTests.Job())); + Assert.Throws(() => LegacyPlateNesterAdapter.Create("not registered")); + } + + [Fact] + public void ExactGeometryRoundTripsIncludingOriginArcHoleAndMode() + { + var program = TestDrawingFactory.Rectangle(); + program.Offset(-17.123456789, 5.25); + program.MoveTo(-14, 9); + program.ArcTo(-14, 9, -13, 9, RotationType.CW); + ((ArcMove)program.Codes[^1]).Layer = LayerType.Cut; + var drawing = new Drawing("shape", program); + var part = DrawingJobMapper.FromDrawing("p", drawing, 1); + var snapshot = part.Geometry.Motions.ToArray(); + ((Motion)program.Codes[1]).EndPoint = new Vector(999, 888); + Assert.Equal(snapshot, part.Geometry.Motions); + var roundTrip = DrawingJobMapper.ToProgram(part.Geometry); + Assert.Equal(snapshot, PartGeometrySnapshot.FromProgram(roundTrip).Motions); + Assert.Equal(program.Mode, roundTrip.Mode); + roundTrip.Codes.Clear(); + Assert.Equal(snapshot, part.Geometry.Motions); + var incremental = new Program(Mode.Incremental); + incremental.MoveTo(5, -3); + incremental.LineTo(7, 4); + var incrementalSnapshot = PartGeometrySnapshot.FromProgram(incremental); + Assert.Equal(Mode.Incremental, DrawingJobMapper.ToProgram(incrementalSnapshot).Mode); + Assert.Equal(incrementalSnapshot.Motions, + PartGeometrySnapshot.FromProgram(DrawingJobMapper.ToProgram(incrementalSnapshot)).Motions); + } + + [Fact] + public void RealDefaultEngineRunsFromDrawingThroughMaterialization() + { + var drawing = new Drawing("generated asymmetric rectangle", TestDrawingFactory.Rectangle(13, 7)); + drawing.Quantity.Required = 1; + var job = new NestJob(new[] { DrawingJobMapper.FromDrawing("rectangle", drawing, 1) }, + new[] { new NestPlateStock("sheet", new Size(40, 60), 1, 1, new Spacing(2, 2, 2, 2)) }); + var result = new NestJobRunner(LegacyPlateNesterAdapter.Create).Solve(job); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(new StockUsage("sheet", 1, 0), Assert.Single(result.StockUsage)); + var pose = Assert.Single(Assert.Single(result.Plates).Placements); + var output = NestResultMaterializer.Materialize(job, result); + var physicalPlate = Assert.Single(output.Nest.Plates); + var placed = Assert.Single(physicalPlate.Parts); + Assert.Equal(1, physicalPlate.Quantity); + Assert.Same(output.DrawingsByPartId["rectangle"], placed.BaseDrawing); + Assert.Equal(pose.X, placed.Location.X); + Assert.Equal(pose.Y, placed.Location.Y); + Assert.Equal(pose.Rotation, placed.Rotation, 10); + Assert.Equal(1, placed.BaseDrawing.Quantity.Nested); + Assert.Equal(0, drawing.Quantity.Nested); + Assert.Equal(1, drawing.Quantity.Required); + var bounds = placed.BoundingBox; + var work = physicalPlate.WorkArea(); + Assert.True(bounds.Left >= work.Left - 1e-6 && bounds.Bottom >= work.Bottom - 1e-6); + Assert.True(bounds.Right <= work.Right + 1e-6 && bounds.Top <= work.Top + 1e-6); + } + + [Fact] + public void MaterializationRotatesAboutSnapshotOriginThenTranslates() + { + var program = TestDrawingFactory.Rectangle(); + program.Offset(-5, 3); + var job = new NestJob(new[] { new NestJobPart("p", PartGeometrySnapshot.FromProgram(program), 1) }, + FiniteStockJobTests.Job(1).Plates); + var result = new NestJobRunner(_ => new FiniteStockJobTests.Nester(_ => new PlateCandidate(new[] + { new NestJobPlacement("p", 0, 23, 31, 0.7) }))).Solve(job); + var output = NestResultMaterializer.Materialize(job, result); + var part = output.Nest.Plates[0].Parts[0]; + var expected = new Vector(-5, 3).Rotate(0.7); + Assert.Equal(expected.X, ((Motion)part.Program.Codes[0]).EndPoint.X, 10); + Assert.Equal(expected.Y, ((Motion)part.Program.Codes[0]).EndPoint.Y, 10); + Assert.Equal(new Vector(23, 31), part.Location); + } + + private sealed class MutatingEngine(Plate plate, Func, List> nest) : NestEngineBase(plate) + { + public override string Name => "test"; + public override string Description => "mutates private demand"; + public override List Nest(List items, IProgress progress, CancellationToken token) + => nest(items); + } +} diff --git a/OpenNest.Engine.Tests/Jobs/NestJobRunnerTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobRunnerTests.cs index 5b955f2..5b30faf 100644 --- a/OpenNest.Engine.Tests/Jobs/NestJobRunnerTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestJobRunnerTests.cs @@ -41,12 +41,15 @@ public class NestJobRunnerTests } [Fact] - public void NonemptyJobIsExplicitlyUnsupportedInContractSlice() + public void EmptyStockReturnsIncomplete() { var part = new NestJobPart("part", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle()), 1); var job = new NestJob(new[] { part }, Array.Empty()); var runner = new NestJobRunner(_ => new FakePlateNester()); - Assert.Throws(() => runner.Solve(job)); + var result = runner.Solve(job); + Assert.Equal(NestJobStatus.Incomplete, result.Status); + Assert.Equal(NestJobStopReason.StockExhausted, result.StopReason); + Assert.Equal(1, Assert.Single(result.Fulfillment).Unplaced); } [Fact] diff --git a/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs new file mode 100644 index 0000000..c02185f --- /dev/null +++ b/OpenNest.Engine/Jobs/Adapters/DrawingJobMapper.cs @@ -0,0 +1,77 @@ +using System; +using OpenNest.CNC; + +namespace OpenNest; + +/// Explicit-ID input mapping and exact supported-geometry reconstruction. Never retains caller objects. +public static class DrawingJobMapper +{ + public static NestJobPart FromDrawing(string partId, Drawing drawing, int quantity) + { + ArgumentNullException.ThrowIfNull(drawing); + var constraints = drawing.Constraints; + return new NestJobPart(partId, PartGeometrySnapshot.FromProgram(drawing.Program), quantity, drawing.Priority, + constraints == null ? RotationPolicy.Automatic : + RotationPolicy.FromLegacy(constraints.StepAngle, constraints.StartAngle, constraints.EndAngle)); + } + + public static NestJobPart FromItem(string partId, NestItem item) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(item.Drawing); + return new NestJobPart(partId, PartGeometrySnapshot.FromProgram(item.Drawing.Program), item.Quantity, + item.Priority, RotationPolicy.FromLegacy(item.StepAngle, item.RotationStart, item.RotationEnd)); + } + + /// Available stock is explicit; the legacy plate repeat count is not inventory. + public static NestPlateStock FromPlate(string stockId, Plate plate, int? quantity) + { + ArgumentNullException.ThrowIfNull(plate); + return new NestPlateStock(stockId, plate.Size, quantity, plate.PartSpacing, plate.EdgeSpacing, plate.Quadrant); + } + + public static Program ToProgram(PartGeometrySnapshot geometry) + { + ArgumentNullException.ThrowIfNull(geometry); + var program = new Program(geometry.Mode); + foreach (var motion in geometry.Motions) + { + var code = motion.Type switch + { + CodeType.RapidMove => (Motion)new RapidMove(motion.X, motion.Y), + CodeType.LinearMove => new LinearMove(motion.X, motion.Y) { Layer = motion.Layer }, + CodeType.ArcMove => new ArcMove(motion.X, motion.Y, motion.CenterX, motion.CenterY, motion.Rotation) + { Layer = motion.Layer }, + _ => throw new NotSupportedException("Unsupported snapshot motion.") + }; + code.Suppressed = motion.Suppressed; + program.Codes.Add(code); + } + return program; + } + + internal static Drawing CreateDrawing(NestJobPart part) + { + var drawing = new Drawing(part.Id, ToProgram(part.Geometry)) { Priority = part.Priority }; + drawing.Quantity.Required = part.Quantity; + drawing.Constraints = new NestConstraints + { + StepAngle = LegacyStep(part.Rotation), + StartAngle = part.Rotation.Start, + EndAngle = part.Rotation.End + }; + return drawing; + } + + // A fixed angle needs a nonzero legacy step so it is not misread as automatic. + internal static double LegacyStep(RotationPolicy policy) => policy.Kind == RotationPolicyKind.Fixed + ? OpenNest.Math.Angle.TwoPI : policy.Step; + + internal static Plate CreatePlate(NestPlateStock stock) => new(stock.Size) + { + Quantity = 1, + PartSpacing = stock.PartSpacing, + EdgeSpacing = stock.EdgeSpacing, + Quadrant = stock.Quadrant + }; +} diff --git a/OpenNest.Engine/Jobs/Adapters/LegacyPlateNesterAdapter.cs b/OpenNest.Engine/Jobs/Adapters/LegacyPlateNesterAdapter.cs new file mode 100644 index 0000000..9aeb8a9 --- /dev/null +++ b/OpenNest.Engine/Jobs/Adapters/LegacyPlateNesterAdapter.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace OpenNest; + +/// +/// A fresh private legacy plate/drawing/item graph for each call. Only returned poses cross the boundary; +/// legacy quantity mutations are deliberately ignored. Does not certify geometric safety or rotation compliance. +/// +public sealed class LegacyPlateNesterAdapter : IPlateNester +{ + private readonly Func engineFactory; + + public LegacyPlateNesterAdapter(Func engineFactory) + { + ArgumentNullException.ThrowIfNull(engineFactory); + this.engineFactory = engineFactory; + } + + /// Minimal built-in selection; never reads or changes NestEngineRegistry. + public static IPlateNester Create(string strategy) => strategy == "Default" + ? new LegacyPlateNesterAdapter(plate => new DefaultNestEngine(plate)) + : throw new NotSupportedException($"Unknown placement strategy: {strategy}."); + + public PlateCandidate Place(PlatePlacementRequest request, IProgress progress = null, + CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(request); + token.ThrowIfCancellationRequested(); + var plate = DrawingJobMapper.CreatePlate(request.Stock); + var items = new List(); + var identities = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (var requirement in request.Parts) + { + var drawing = DrawingJobMapper.CreateDrawing(requirement); + identities.Add(drawing, requirement.Id); + items.Add(new NestItem + { + Drawing = drawing, + Quantity = requirement.Quantity, + Priority = requirement.Priority, + StepAngle = DrawingJobMapper.LegacyStep(requirement.Rotation), + RotationStart = requirement.Rotation.Start, + RotationEnd = requirement.Rotation.End + }); + } + var engine = engineFactory(plate) ?? throw new InvalidOperationException("Legacy engine factory returned null."); + var parts = engine.Nest(items, null, token); + token.ThrowIfCancellationRequested(); + if (parts == null) throw new InvalidOperationException("Legacy engine returned null placements."); + var placements = new List(); + foreach (var part in parts) + { + if (part?.BaseDrawing == null || !identities.TryGetValue(part.BaseDrawing, out var id)) + throw new InvalidOperationException("Legacy placement does not reference a private requirement drawing."); + placements.Add(new NestJobPlacement(id, 0, part.Location.X, part.Location.Y, part.Rotation)); + } + return new PlateCandidate(placements); + } +} diff --git a/OpenNest.Engine/Jobs/Adapters/NestResultMaterializer.cs b/OpenNest.Engine/Jobs/Adapters/NestResultMaterializer.cs new file mode 100644 index 0000000..a4c16d4 --- /dev/null +++ b/OpenNest.Engine/Jobs/Adapters/NestResultMaterializer.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using OpenNest.Geometry; + +namespace OpenNest; + +/// A detached mutable domain nest plus explicit requirement identity (never inferred from names). +public sealed class MaterializedNestResult +{ + internal MaterializedNestResult(Nest nest, Dictionary drawings) + { + Nest = nest; + DrawingsByPartId = new ReadOnlyDictionary(drawings); + } + + public Nest Nest { get; } + public IReadOnlyDictionary DrawingsByPartId { get; } +} + +/// Materializes a result from the same job. Geometry safety remains the solver's future validation boundary. +public static class NestResultMaterializer +{ + public static MaterializedNestResult Materialize(NestJob job, NestJobResult result) + { + ArgumentNullException.ThrowIfNull(job); + ArgumentNullException.ThrowIfNull(result); + var nest = new Nest(); + var drawings = job.Parts.ToDictionary(p => p.Id, DrawingJobMapper.CreateDrawing, StringComparer.Ordinal); + foreach (var drawing in drawings.Values) nest.Drawings.Add(drawing); + foreach (var sheet in result.Plates) + { + var plate = DrawingJobMapper.CreatePlate(sheet.Stock); + foreach (var pose in sheet.Placements) + { + if (!drawings.TryGetValue(pose.PartId, out var drawing)) + throw new ArgumentException("Result contains a requirement not present in the job.", nameof(result)); + // Do not use CreateAtOrigin: it normalizes bounds and would change the snapshot frame. + var part = new Part(drawing); + part.Rotate(pose.Rotation); + part.Location = new Vector(pose.X, pose.Y); + part.UpdateBounds(); + // Quantity=1 is set before the only attachment; Plate's event owns Nested accounting. + plate.Parts.Add(part); + } + nest.Plates.Add(plate); + } + return new MaterializedNestResult(nest, drawings); + } +} diff --git a/OpenNest.Engine/Jobs/NestJobRunner.cs b/OpenNest.Engine/Jobs/NestJobRunner.cs index d3f12b8..023c145 100644 --- a/OpenNest.Engine/Jobs/NestJobRunner.cs +++ b/OpenNest.Engine/Jobs/NestJobRunner.cs @@ -1,15 +1,19 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading; namespace OpenNest; -/// Contract-stage runner: empty jobs only. Nonempty allocation is not implemented yet. +/// +/// Single-stock physical-sheet allocation. Candidate accounting is validated before commit; +/// full geometry, clearance, and rotation-policy validation is not implemented yet. +/// public sealed class NestJobRunner : INestingEngine { private readonly Func plateNesterFactory; - /// Stores a runner-local strategy factory; never consults the global engine registry. + /// Runner-local strategy resolution. A factory must reject unknown keys or return null. public NestJobRunner(Func plateNesterFactory) { ArgumentNullException.ThrowIfNull(plateNesterFactory); @@ -21,10 +25,58 @@ public sealed class NestJobRunner : INestingEngine { ArgumentNullException.ThrowIfNull(job); token.ThrowIfCancellationRequested(); + NestJobValidator.Validate(job); + var plates = new List(); + var remaining = job.Parts.ToDictionary(p => p.Id, p => p.Quantity, StringComparer.Ordinal); + var placed = job.Parts.ToDictionary(p => p.Id, _ => 0, StringComparer.Ordinal); + var reason = NestJobStopReason.Completed; if (job.Parts.Count != 0) - throw new NotSupportedException("Whole-job allocation is not implemented yet; only empty jobs are supported."); - return new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, - Array.Empty(), Array.Empty(), - job.Plates.Select(stock => new StockUsage(stock.Id, 0, stock.Quantity))); + { + var nester = plateNesterFactory(job.Options.PlacementStrategy) ?? + throw new NotSupportedException($"Unknown placement strategy: {job.Options.PlacementStrategy}."); + var stock = job.Plates.SingleOrDefault(); + while (remaining.Values.Any(count => count > 0)) + { + token.ThrowIfCancellationRequested(); + if (stock == null || stock.Quantity <= plates.Count) + { + reason = NestJobStopReason.StockExhausted; + break; + } + if (job.Options.MaxPlates <= plates.Count) + { + reason = NestJobStopReason.PlateLimitReached; + break; + } + var request = new PlatePlacementRequest(stock, job.Parts.Where(p => remaining[p.Id] > 0) + .Select(p => new NestJobPart(p.Id, p.Geometry, remaining[p.Id], p.Priority, p.Rotation))); + progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stock.Id, + plates.Count, plates.Count, placed.Values.Sum())); + token.ThrowIfCancellationRequested(); + // Do not forward legacy/candidate progress as committed production. + var candidate = nester.Place(request, token: token); + token.ThrowIfCancellationRequested(); + NestJobValidator.ValidateCandidate(candidate, remaining); + if (candidate.Placements.Count == 0) + { + reason = NestJobStopReason.NoPlacementFound; + break; + } + var committed = new List(); + foreach (var pose in candidate.Placements) + { + committed.Add(pose with { InstanceIndex = placed[pose.PartId]++ }); + remaining[pose.PartId]--; + } + plates.Add(new NestJobPlateResult(plates.Count, stock, committed)); + progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, stock.Id, + plates.Count - 1, plates.Count, placed.Values.Sum())); + } + } + token.ThrowIfCancellationRequested(); + return new NestJobResult(reason == NestJobStopReason.Completed ? NestJobStatus.Complete : NestJobStatus.Incomplete, + reason, plates, job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, placed[p.Id], remaining[p.Id])), + job.Plates.Select(stock => new StockUsage(stock.Id, plates.Count(p => p.StockId == stock.Id), + stock.Quantity - plates.Count(p => p.StockId == stock.Id)))); } } diff --git a/OpenNest.Engine/Jobs/NestJobValidator.cs b/OpenNest.Engine/Jobs/NestJobValidator.cs new file mode 100644 index 0000000..7967d5f --- /dev/null +++ b/OpenNest.Engine/Jobs/NestJobValidator.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest; + +/// Basic input and candidate accounting checks, NOT a geometry/clearance safety gate. +public static class NestJobValidator +{ + public static void Validate(NestJob job) + { + ArgumentNullException.ThrowIfNull(job); + foreach (var stock in job.Plates) + { + var edges = stock.EdgeSpacing; + if (!Positive(stock.Size.Width) || !Positive(stock.Size.Length) || + !Nonnegative(stock.PartSpacing) || !Nonnegative(edges.Left) || !Nonnegative(edges.Right) || + !Nonnegative(edges.Top) || !Nonnegative(edges.Bottom) || stock.Quadrant < 1 || stock.Quadrant > 4 || + edges.Left + edges.Right >= stock.Size.Length || edges.Top + edges.Bottom >= stock.Size.Width) + throw new ArgumentException($"Invalid stock dimensions/settings: {stock.Id}.", nameof(job)); + } + foreach (var part in job.Parts) + { + if (part.Geometry.Motions.Count == 0 || part.Geometry.Motions.Any(m => + !double.IsFinite(m.X) || !double.IsFinite(m.Y) || + !double.IsFinite(m.CenterX) || !double.IsFinite(m.CenterY))) + throw new ArgumentException($"Geometry must contain finite motions: {part.Id}.", nameof(job)); + } + // Empty jobs do not select or consume stock (including multiple unused stock entries). + if (job.Parts.Count != 0 && job.Plates.Count > 1) + throw new NotSupportedException("This slice supports one stock entry only; mixed-stock selection is not implemented."); + } + + internal static void ValidateCandidate(PlateCandidate candidate, IReadOnlyDictionary remaining) + { + if (candidate == null) throw new InvalidOperationException("The plate nester returned a null candidate."); + var counts = new Dictionary(StringComparer.Ordinal); + foreach (var placement in candidate.Placements) + { + if (placement.PartId == null || !remaining.TryGetValue(placement.PartId, out var available)) + throw new InvalidOperationException("Candidate references an unknown requirement ID."); + if (!double.IsFinite(placement.X) || !double.IsFinite(placement.Y) || !double.IsFinite(placement.Rotation)) + throw new InvalidOperationException("Candidate poses must be finite."); + counts.TryGetValue(placement.PartId, out var count); + if (count >= available) throw new InvalidOperationException("Candidate overproduces a requirement."); + counts[placement.PartId] = count + 1; + } + } + + private static bool Positive(double value) => double.IsFinite(value) && value > 0; + private static bool Nonnegative(double value) => double.IsFinite(value) && value >= 0; +} diff --git a/README.md b/README.md index dff8ed9..83c63fa 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,21 @@ dotnet test OpenNest.Engine.Tests/OpenNest.Engine.Tests.csproj The new whole-job contracts in `OpenNest.Engine/Jobs` (`namespace OpenNest`) use owned immutable geometry/settings, explicit part IDs and positive demand, finite or unlimited stock (`null` means unlimited; zero means unavailable), and result ID/pose values rather than mutable desktop models. Rotation is in radians about the geometry origin, followed by translation into the plate quadrant frame. Strategy factories belong to each runner, not the global registry. -**Current scope:** `NestJobRunner.Solve` completes empty jobs without consuming stock and honors initial cancellation by throwing. Nonempty jobs explicitly throw `NotSupportedException`; allocation, legacy adapters, placement validation, and production strategy resolution are not implemented yet. Geometry snapshots currently preserve flat CNC rapid/line/arc programs, including hole contours, without approximation; other instructions are explicitly rejected. Existing desktop, API, CLI, and MCP nesting paths are unchanged. +**Current scope (minimum runnable slice, not release-ready):** `NestJobRunner.Solve` allocates one stock entry across multiple physical sheets, respecting finite inventory (`null` is unlimited), positive remaining demand, and `MaxPlates`. Empty parts complete without consuming stock; empty/unavailable stock returns `Incomplete/StockExhausted`. A zero-placement candidate stops with `NoPlacementFound` and consumes no sheet. Nonempty jobs with multiple stock entries explicitly throw `NotSupportedException`; mixed-stock selection and optional optimizations are not implemented. + +`DrawingJobMapper` snapshots caller drawings/items under explicit requirement IDs. `LegacyPlateNesterAdapter` creates fresh private legacy drawings, items, and plates for each trial and maps returned drawings **by reference**, never by name. Mutable legacy quantities never drive the fulfillment ledger. The minimal built-in factory accepts only `Default`; injected factories must explicitly reject unknown keys or return null. `NestResultMaterializer` returns a detached domain nest and `DrawingsByPartId` identity map. Each output plate represents one physical sheet (`Quantity = 1`), and each placement is attached exactly once so domain quantity events do not double count. + +```csharp +var job = new NestJob( + new[] { DrawingJobMapper.FromDrawing("requirement-1", drawing, quantity: 3) }, + new[] { DrawingJobMapper.FromPlate("stock-1", plateTemplate, quantity: 3) }); +var result = new NestJobRunner(LegacyPlateNesterAdapter.Create).Solve(job); +var domainResult = NestResultMaterializer.Materialize(job, result); +// result contains fulfillment/unplaced counts and physical stock usage; +// domainResult.Nest and domainResult.DrawingsByPartId are detached from caller objects. +``` + +**Unfinished safety boundary:** basic validation rejects invalid dimensions/settings, nonfinite geometry, unknown candidate IDs, nonfinite poses, and overproduction. Cancellation throws initially and immediately after the engine returns; no partial result is returned. Full contour validity, plate containment, allowed-rotation enforcement, inter-part overlap/clearance validation, and broader cancellation coverage remain task 5 work. A committed candidate is therefore **not yet certified safe for cutting**, even if the legacy engine reports success. Geometry snapshots preserve flat CNC rapid/line/arc programs, including origin and hole contours, without approximation; other instructions are explicitly rejected. Existing desktop, API, CLI, and MCP nesting paths are unchanged. ### Run