From eb8fbec1aa42daa678fd371a7bc345ca10164162 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 21 Sep 2026 18:02:54 -0400 Subject: [PATCH] refactor(engine): make jobs plate nesters filler-backed --- .../Jobs/GoldenLayoutTests.cs | 4 +- OpenNest.Engine.Tests/Jobs/JobAdapterTests.cs | 137 +------------ .../Jobs/NestJobCancellationTests.cs | 14 +- .../Jobs/NestJobIdentityTests.cs | 35 ++-- .../Jobs/NesterContractTests.cs | 180 ++++++++++++++++++ .../Jobs/PlateNesterParityTests.cs | 137 +++++++------ .../Jobs/Adapters/LegacyPlateNesterAdapter.cs | 74 ------- .../Jobs/Placement/DefaultPlateNester.cs | 42 ++-- .../Jobs/Placement/RemnantPlateNester.cs | 68 +++++++ .../Jobs/Placement/StripPlateNester.cs | 32 ++-- OpenNest.Engine/Jobs/PlateNesterFactory.cs | 17 +- README.md | 6 +- 12 files changed, 401 insertions(+), 345 deletions(-) create mode 100644 OpenNest.Engine.Tests/Jobs/NesterContractTests.cs delete mode 100644 OpenNest.Engine/Jobs/Adapters/LegacyPlateNesterAdapter.cs create mode 100644 OpenNest.Engine/Jobs/Placement/RemnantPlateNester.cs diff --git a/OpenNest.Engine.Tests/Jobs/GoldenLayoutTests.cs b/OpenNest.Engine.Tests/Jobs/GoldenLayoutTests.cs index 0b80950..a7024e6 100644 --- a/OpenNest.Engine.Tests/Jobs/GoldenLayoutTests.cs +++ b/OpenNest.Engine.Tests/Jobs/GoldenLayoutTests.cs @@ -9,8 +9,8 @@ namespace OpenNest.Engine.Tests.Jobs; /// /// Golden-layout fixtures: the permanent regression net for the legacy-engine removal. /// Each test solves a fixed job through the production path -/// ( + , which for the remnant -/// strategies still routes through ) and asserts the +/// ( + , all four strategies +/// filler-backed) and asserts the /// exact committed poses captured from the pre-migration code. The extraction phases must /// keep these green byte-for-byte (modulo 1e-9 float noise). /// diff --git a/OpenNest.Engine.Tests/Jobs/JobAdapterTests.cs b/OpenNest.Engine.Tests/Jobs/JobAdapterTests.cs index 548f4eb..977d5ee 100644 --- a/OpenNest.Engine.Tests/Jobs/JobAdapterTests.cs +++ b/OpenNest.Engine.Tests/Jobs/JobAdapterTests.cs @@ -5,125 +5,13 @@ using OpenNest.Engine.Jobs.Adapters; namespace OpenNest.Engine.Tests.Jobs; +/// +/// Domain-boundary adapters: geometry snapshots, mapper round-trips, and materialization. The +/// former adapter-vs-runner contract tests moved to when the +/// legacy plate-nester adapter was deleted in the jobs-only placement migration. +/// 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 new List - { - new Part(items[0].Drawing, new Vector(0, 0)), - new Part(items[1].Drawing, new Vector(10, 0)), - }; - } - )); - 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() { @@ -167,7 +55,7 @@ public class JobAdapterTests 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); + var result = new NestJobRunner(PlateNesterFactory.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); @@ -207,17 +95,4 @@ public class JobAdapterTests 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/NestJobCancellationTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobCancellationTests.cs index ce60085..c8580a5 100644 --- a/OpenNest.Engine.Tests/Jobs/NestJobCancellationTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestJobCancellationTests.cs @@ -1,7 +1,8 @@ using OpenNest.CNC; using OpenNest.Geometry; using OpenNest.Engine.Jobs; -using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Engine.Jobs.Placement; +using OpenNest.Engine.Jobs.Placement.Fillers; namespace OpenNest.Engine.Tests.Jobs; @@ -84,9 +85,9 @@ public class NestJobCancellationTests new[] { part }, new[] { new NestPlateStock("stock", new Size(20, 30), 1) } ); - var runner = new NestJobRunner(_ => new LegacyPlateNesterAdapter( - plate => new ReportingEngine(plate) - )); + var runner = new NestJobRunner(_ => new DefaultPlateNester(plate => new ReportingFiller( + plate + ))); var result = runner.Solve(job, new InlineProgress(reports.Add)); @@ -134,11 +135,8 @@ public class NestJobCancellationTests } } - private sealed class ReportingEngine(Plate plate) : NestEngineBase(plate) + private sealed class ReportingFiller(Plate plate) : DefaultPlateFiller(plate) { - public override string Name => "reporting"; - public override string Description => "reports progress"; - public override List Nest( List items, IProgress? progress, diff --git a/OpenNest.Engine.Tests/Jobs/NestJobIdentityTests.cs b/OpenNest.Engine.Tests/Jobs/NestJobIdentityTests.cs index 71c11d9..a551f4e 100644 --- a/OpenNest.Engine.Tests/Jobs/NestJobIdentityTests.cs +++ b/OpenNest.Engine.Tests/Jobs/NestJobIdentityTests.cs @@ -1,15 +1,18 @@ using OpenNest.CNC; -using OpenNest.Engine.Fill; using OpenNest.Geometry; using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Engine.Jobs.Placement; +using OpenNest.Engine.Jobs.Placement.Fillers; namespace OpenNest.Engine.Tests.Jobs; /// /// Names are never identity: two distinct drawings that share a display name must keep /// independent quantities, and two requirements that share one source drawing must not -/// cross-count each other's placements through the legacy engine paths. +/// cross-count each other's placements. Production identity runs through the built-in nesters' +/// ; the filler probes exercise the orchestrator's +/// reference-identity quantity deduction directly. /// public class NestJobIdentityTests { @@ -27,7 +30,7 @@ public class NestJobIdentityTests new[] { new NestPlateStock("s", new Size(90, 90), 1) } ); - var result = new NestJobRunner(LegacyPlateNesterAdapter.Create).Solve(job); + var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job); // Every placed part maps to a known requirement ID; no part is invented or cross-counted. Assert.True(result.Plates.SelectMany(p => p.Placements).All(p => p.PartId is "a" or "b")); @@ -56,7 +59,7 @@ public class NestJobIdentityTests new[] { new NestPlateStock("s", new Size(90, 90), 1) } ); - var result = new NestJobRunner(LegacyPlateNesterAdapter.Create).Solve(job); + var result = new NestJobRunner(PlateNesterFactory.Create).Solve(job); Assert.Equal(new[] { "first", "second" }, result.Fulfillment.Select(f => f.PartId)); foreach (var f in result.Fulfillment) @@ -72,7 +75,7 @@ public class NestJobIdentityTests [Fact] public void EngineDeductionCountsByDrawingReferenceNotName() { - // Plate 90x40 fits exactly two 40x40 parts. The engine fills item A with both and + // Plate 90x40 fits exactly two 40x40 parts. The filler fills item A with both and // starves item B. Name-based deduction would then zero BOTH items (the two placed // parts carry the shared name, so each item counts 2 as "its own"). Reference-based // deduction leaves B at 2. @@ -84,7 +87,7 @@ public class NestJobIdentityTests new() { Drawing = a, Quantity = 2 }, new() { Drawing = b, Quantity = 2 }, }; - // Place exactly 2 parts from item A and none from item B, then run the base-class + // Place exactly 2 parts from item A and none from item B, then run the orchestrator's // deduction. Deterministic regardless of any fill heuristic. var placed = new StarvingProbe(plate).Nest(items, null, default); @@ -111,45 +114,41 @@ public class NestJobIdentityTests new() { Drawing = a, Quantity = 1 }, new() { Drawing = b, Quantity = 1 }, }; - var placed = new BaseNestEngineProbe(plate).Nest(items, null, default); + var placed = new DefaultFillerProbe(plate).Nest(items, null, default); Assert.Equal(2, placed.Count); Assert.Equal(new[] { 0, 0 }, new[] { items[0].Quantity, items[1].Quantity }); } - private sealed class BaseNestEngineProbe(Plate plate) : NestEngineBase(plate) + /// Orchestrator probe whose fill/pack delegates run the Default filler. + private sealed class DefaultFillerProbe(Plate plate) : PlateFillerBase(plate) { - public override string Name => "probe"; - public override string Description => "probe"; - public override List Fill( NestItem item, Box workArea, IProgress progress, CancellationToken token - ) => new DefaultNestEngine(Plate).Fill(item, workArea, progress, token); + ) => new DefaultPlateFiller(Plate).Fill(item, workArea, progress, token); public override List Fill( List groupParts, Box workArea, IProgress progress, CancellationToken token - ) => new DefaultNestEngine(Plate).Fill(groupParts, workArea, progress, token); + ) => new DefaultPlateFiller(Plate).Fill(groupParts, workArea, progress, token); public override List PackArea( Box box, List items, IProgress progress, CancellationToken token - ) => new DefaultNestEngine(Plate).PackArea(box, items, progress, token); + ) => new DefaultPlateFiller(Plate).PackArea(box, items, progress, token); } /// Places exactly 2 parts from the first multi-quantity item and none from the - /// rest, forcing the base-class deduction to run on an asymmetric placement result. - private sealed class StarvingProbe(Plate plate) : NestEngineBase(plate) + /// rest, forcing the orchestrator's deduction to run on an asymmetric placement result. + private sealed class StarvingProbe(Plate plate) : PlateFillerBase(plate) { private int _first = -1; - public override string Name => "starving"; - public override string Description => "starves all but the first fill item"; public override List Fill( NestItem item, diff --git a/OpenNest.Engine.Tests/Jobs/NesterContractTests.cs b/OpenNest.Engine.Tests/Jobs/NesterContractTests.cs new file mode 100644 index 0000000..ea5aa00 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/NesterContractTests.cs @@ -0,0 +1,180 @@ +using OpenNest.CNC; +using OpenNest.Geometry; +using OpenNest.Engine.Jobs; +using OpenNest.Engine.Jobs.Adapters; + +namespace OpenNest.Engine.Tests.Jobs; + +/// +/// The jobs-boundary contract that and +/// share with every . Retargeted from the +/// deleted legacy adapter's tests: the private-geometry, reference-identity, and unknown-part +/// guarantees are boundary properties, so they are proven with a nester stub that mutates its +/// private items exactly as an engine would. +/// +public class NesterContractTests +{ + [Fact] + public void NesterMutationsCannotDoubleSubtractOrReachCallerObjects() + { + 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 nester = new RecordingNester( + 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(_ => nester).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 ReferenceIdentityNotNamesControlsPlacements() + { + 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 nester = new RecordingNester( + items => + { + Assert.NotSame(items[0].Drawing, items[1].Drawing); + foreach (var item in items) + item.Drawing.Name = "identical"; + return new List + { + new Part(items[0].Drawing, new Vector(0, 0)), + new Part(items[1].Drawing, new Vector(10, 0)), + }; + } + ); + var result = new NestJobRunner(_ => nester).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 nester = new RecordingNester( + items => + new List + { + new(new Drawing(items[0].Drawing.Name, TestDrawingFactory.Rectangle())), + } + ); + Assert.Throws(() => + new NestJobRunner(_ => nester).Solve(FiniteStockJobTests.Job()) + ); + Assert.Throws(() => PlateNesterFactory.Create("not registered")); + } + + /// Runs a caller-supplied function over freshly built private items and maps the + /// returned parts back by Drawing reference — the same boundary mechanics as the production + /// , but rebuilt per call so each trial is independent. + private sealed class RecordingNester(Func, List> propose) : IPlateNester + { + public PlateCandidate Place( + PlatePlacementRequest request, + IProgress? progress = null, + CancellationToken token = default + ) + { + var items = new List(); + var idsByDrawing = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (var requirement in request.Parts) + { + var drawing = DrawingJobMapper.CreateDrawing(requirement); + idsByDrawing.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, + } + ); + } + return new PlateCandidate( + propose(items).Select(p => new NestJobPlacement( + IdFor(idsByDrawing, p), + 0, + p.Location.X, + p.Location.Y, + p.Rotation + )) + ); + } + + private static string IdFor(Dictionary idsByDrawing, Part part) + { + // Deliberately reference-based, like the placement context. + if (part?.BaseDrawing == null || !idsByDrawing.TryGetValue(part.BaseDrawing, out var id)) + throw new InvalidOperationException( + "Placement does not reference a known requirement drawing." + ); + return id; + } + } +} diff --git a/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs b/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs index f75f2cd..a292a23 100644 --- a/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs +++ b/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs @@ -2,16 +2,15 @@ using OpenNest.CNC; using OpenNest.Geometry; using Xunit; using OpenNest.Engine.Jobs; -using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Placement; namespace OpenNest.Engine.Tests.Jobs; /// -/// Parity between the legacy adapter and the migrated built-in plate nesters (Default/Strip) on -/// generated geometry. The runner's placement validator enforces geometric safety on every committed -/// candidate, so these tests assert fulfillment, status, and — for the deterministic Default/rectangle -/// case — identical layouts. +/// Direct filler-backed coverage for the built-in plate nesters (Default/Strip/remnant). The +/// runner's placement validator enforces geometric safety on every committed candidate, so these +/// tests assert fulfillment, status, and the identity/rotation safety boundaries; exact committed +/// layouts are pinned separately by . /// public class PlateNesterParityTests { @@ -36,29 +35,6 @@ public class PlateNesterParityTests private static Dictionary ByPart(NestJobResult result) => result.Fulfillment.ToDictionary(f => f.PartId, StringComparer.Ordinal); - private static void AssertLayoutsIdentical(NestJobResult left, NestJobResult right) - { - Assert.Equal(left.Plates.Count, right.Plates.Count); - for (var i = 0; i < left.Plates.Count; i++) - { - var lPlates = left.Plates[i].Placements; - var rPlates = right.Plates[i].Placements; - Assert.Equal(lPlates.Count, rPlates.Count); - var lSorted = lPlates.OrderBy(p => p.PartId).ThenBy(p => p.X).ThenBy(p => p.Y).ToList(); - var rSorted = rPlates.OrderBy(p => p.PartId).ThenBy(p => p.X).ThenBy(p => p.Y).ToList(); - for (var j = 0; j < lSorted.Count; j++) - { - Assert.Equal(lSorted[j].PartId, rSorted[j].PartId); - Assert.Equal(lSorted[j].X, rSorted[j].X, 6); - Assert.Equal(lSorted[j].Y, rSorted[j].Y, 6); - Assert.True( - AnglesEqual(lSorted[j].Rotation, rSorted[j].Rotation), - $"rotation differs: {lSorted[j].Rotation} vs {rSorted[j].Rotation}" - ); - } - } - } - private static bool AnglesEqual(double left, double right) { var delta = (left - right) % (System.Math.PI * 2); @@ -67,7 +43,7 @@ public class PlateNesterParityTests } [Fact] - public void DefaultParity_Rectangles_SameFulfillmentAndLayout() + public void DefaultDirectFiller_Rectangles_FulfilledAndValid() { var parts = new[] { @@ -83,26 +59,18 @@ public class PlateNesterParityTests ), }; - var legacy = Solve( - new LegacyPlateNesterAdapter(plate => new DefaultNestEngine(plate)), - Job(parts) - ); - var migrated = Solve(new DefaultPlateNester(), Job(parts)); + var result = Solve(new DefaultPlateNester(), Job(parts)); - Assert.Equal(legacy.Status, migrated.Status); - Assert.Equal(NestJobStatus.Complete, migrated.Status); - Assert.Equal(ByPart(legacy), ByPart(migrated)); - foreach (var usage in legacy.StockUsage) - Assert.Equal( - usage.Used, - migrated.StockUsage.First(u => u.StockId == usage.StockId).Used - ); - // Automatic-rotation rectangles on a single stock size are deterministic: identical layouts. - AssertLayoutsIdentical(legacy, migrated); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(4, ByPart(result)["a"].Placed); + Assert.Equal(3, ByPart(result)["b"].Placed); + var usage = Assert.Single(result.StockUsage); + Assert.Equal(1, usage.Used); + Assert.Equal(7, result.Plates.SelectMany(p => p.Placements).Count()); } [Fact] - public void StripParity_Rectangles_SameFulfillmentAndTotalCount() + public void StripDirectFiller_Rectangles_FulfilledAndValid() { var parts = new[] { @@ -118,29 +86,22 @@ public class PlateNesterParityTests ), }; - var legacy = Solve( - new LegacyPlateNesterAdapter(plate => new StripNestEngine(plate)), - Job(parts, strategy: "Strip") - ); - var migrated = Solve(new StripPlateNester(), Job(parts, strategy: "Strip")); + var result = Solve(new StripPlateNester(), Job(parts, strategy: "Strip")); - Assert.Equal(legacy.Status, migrated.Status); - Assert.Equal(NestJobStatus.Complete, migrated.Status); - Assert.Equal(ByPart(legacy), ByPart(migrated)); - Assert.Equal( - legacy.Plates.SelectMany(p => p.Placements).Count(), - migrated.Plates.SelectMany(p => p.Placements).Count() - ); - // Shrink-fill ordering can differ between engine instances; do not assert identical coordinates. + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Equal(4, ByPart(result)["a"].Placed); + Assert.Equal(3, ByPart(result)["b"].Placed); + Assert.Equal(7, result.Plates.SelectMany(p => p.Placements).Count()); } [Fact] - public void MigratedBuiltins_AreResolvedByProductionFactory() + public void Builtins_AreResolvedByProductionFactory() { Assert.IsType(PlateNesterFactory.Create("Default")); Assert.IsType(PlateNesterFactory.Create("Strip")); - Assert.IsType(PlateNesterFactory.Create("Vertical Remnant")); - Assert.IsType(PlateNesterFactory.Create("Horizontal Remnant")); + Assert.IsType(PlateNesterFactory.Create("Vertical Remnant")); + Assert.IsType(PlateNesterFactory.Create("Horizontal Remnant")); + Assert.Throws(() => PlateNesterFactory.Create("not registered")); } [Fact] @@ -229,6 +190,55 @@ public class PlateNesterParityTests ); } + [Fact] + public void DefaultRestrictedRotation_NeverTouchesFiller() + { + // Safety rule, not layout: any non-automatic rotation must bypass the Default fill + // pipeline entirely — its Pairs/RectBestFit strategies rotate freely and would propose + // forbidden poses. A throwing filler factory proves the filler is never constructed, and + // completion at the locked angle proves OrderedPlateNester handled the request. + var part = new NestJobPart( + "fixed", + PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)), + 2, + rotation: RotationPolicy.Fixed(0) + ); + var nester = new DefaultPlateNester(_ => + throw new InvalidOperationException("filler must not run for restricted rotation") + ); + + var result = Solve(nester, Job(new[] { part })); + + Assert.Equal(NestJobStatus.Complete, result.Status); + var placements = result.Plates.SelectMany(p => p.Placements).ToList(); + Assert.Equal(2, placements.Count); + Assert.All(placements, p => Assert.True(AnglesEqual(p.Rotation, 0))); + } + + [Fact] + public void RemnantRestrictedRotation_NeverTouchesFiller() + { + // Same safety rule for the remnant nester, whose fillers inherit the Default pipeline's + // automatic-rotation limitation. The throwing factory proves the filler is never + // constructed; completion at the locked angle proves OrderedPlateNester handled it. + var part = new NestJobPart( + "fixed", + PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)), + 2, + rotation: RotationPolicy.Fixed(0) + ); + var nester = new RemnantPlateNester(_ => + throw new InvalidOperationException("filler must not run for restricted rotation") + ); + + var result = Solve(nester, Job(new[] { part }, strategy: "Vertical Remnant")); + + Assert.Equal(NestJobStatus.Complete, result.Status); + var placements = result.Plates.SelectMany(p => p.Placements).ToList(); + Assert.Equal(2, placements.Count); + Assert.All(placements, p => Assert.True(AnglesEqual(p.Rotation, 0))); + } + [Fact] public void RepeatedNames_KeepIndependentIdentity() { @@ -294,9 +304,10 @@ public class PlateNesterParityTests } [Fact] - public void LegacyRemnantStrategies_StillResolveThroughAdapter() + public void DirectRemnantStrategies_FulfillThroughFactory() { - // Remnant strategies must keep working through the legacy adapter after the factory change. + // Remnant strategies resolve to the direct remnant nester and still fulfill after the + // legacy adapter was deleted. var part = new NestJobPart( "p", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)), diff --git a/OpenNest.Engine/Jobs/Adapters/LegacyPlateNesterAdapter.cs b/OpenNest.Engine/Jobs/Adapters/LegacyPlateNesterAdapter.cs deleted file mode 100644 index 90c1403..0000000 --- a/OpenNest.Engine/Jobs/Adapters/LegacyPlateNesterAdapter.cs +++ /dev/null @@ -1,74 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; - -namespace OpenNest.Engine.Jobs.Adapters; - -/// -/// 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; - } - - /// Convenience overload delegating to so strategy - /// resolution has a single source of truth; rejects unknown keys. Never reads or changes the - /// process-global NestEngineRegistry. - public static IPlateNester Create(string strategy) => PlateNesterFactory.Create(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 legacyProgress = CandidateProgressBridge.Create(progress, request.Stock.Id); - var parts = engine.Nest(items, legacyProgress, 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/Placement/DefaultPlateNester.cs b/OpenNest.Engine/Jobs/Placement/DefaultPlateNester.cs index eedb3e7..9b85582 100644 --- a/OpenNest.Engine/Jobs/Placement/DefaultPlateNester.cs +++ b/OpenNest.Engine/Jobs/Placement/DefaultPlateNester.cs @@ -3,36 +3,38 @@ using System.Linq; using System.Threading; using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Engine.Jobs.Placement.Fillers; namespace OpenNest.Engine.Jobs.Placement; /// -/// Migrated built-in placement strategy for the whole-job runner. Reuses -/// fill/pack geometry but owns its own run-scoped bookkeeping: remaining demand is read from the -/// request and placement counts are derived from returned placements, so the engine's private -/// mutations never feed back into job accounting. +/// Built-in placement strategy for the whole-job runner. Fills each candidate trial with a +/// on a fresh private plate and owns its own run-scoped +/// bookkeeping: remaining demand is read from the request and placement counts are derived from +/// returned placements, so the filler's private mutations never +/// feed back into job accounting. /// /// /// The identity/progress boundary mechanics live in : one /// private per requirement is created once per solve and reused across every /// candidate trial (the runner reuses one instance per job). This is safe -/// because the engines mutate (per-trial) and canonical-frame copies, +/// because the fillers mutate (per-trial) and canonical-frame copies, /// never the shared or its Quantity. Identity is by Drawing reference, /// never by name. Each trial still gets a fresh private . /// public sealed class DefaultPlateNester : IPlateNester { - private readonly Func engineFactory; + private readonly Func fillerFactory; private readonly OrderedPlateNester restrictedRotationNester = new(); private readonly CandidatePlacementContext context = new(); public DefaultPlateNester() - : this(static plate => new DefaultNestEngine(plate)) { } + : this(static plate => new DefaultPlateFiller(plate)) { } - /// Injectable for tests; defaults to . - public DefaultPlateNester(Func engineFactory) + /// Injectable for tests; defaults to . + internal DefaultPlateNester(Func fillerFactory) { - this.engineFactory = - engineFactory ?? throw new ArgumentNullException(nameof(engineFactory)); + this.fillerFactory = + fillerFactory ?? throw new ArgumentNullException(nameof(fillerFactory)); } public PlateCandidate Place( @@ -44,26 +46,26 @@ public sealed class DefaultPlateNester : IPlateNester ArgumentNullException.ThrowIfNull(request); token.ThrowIfCancellationRequested(); - // The legacy engine cannot express a locked or bounded rotation (start == end == 0 reads - // as "unconstrained") and its Pairs/RectBestFit strategies rotate freely, so it can return - // poses the requirement's RotationPolicy forbids. Restricted requirements go to the + // The Default fill pipeline cannot express a locked or bounded rotation (start == end == 0 + // reads as "unconstrained") and its Pairs/RectBestFit strategies rotate freely, so it can + // return poses the requirement's RotationPolicy forbids. Restricted requirements go to the // policy-aware ordered nester, which only proposes allowed angles and validates each pose. if (request.Parts.Any(part => part.Rotation.Kind != RotationPolicyKind.Automatic)) return restrictedRotationNester.Place(request, progress, token); var plate = DrawingJobMapper.CreatePlate(request.Stock); - // Quantity is the request's remaining demand; the engine may mutate these per-trial items, + // Quantity is the request's remaining demand; the filler may mutate these per-trial items, // and that mutation is deliberately discarded — placement counts come from the result. var items = context.CreateItems(request.Parts); - var engine = - engineFactory(plate) - ?? throw new InvalidOperationException("Engine factory returned null."); + var filler = + fillerFactory(plate) + ?? throw new InvalidOperationException("Filler factory returned null."); var legacyProgress = CandidateProgressBridge.Create(progress, request.Stock.Id); - var parts = engine.Nest(items, legacyProgress, token); + var parts = filler.Nest(items, legacyProgress, token); token.ThrowIfCancellationRequested(); if (parts == null) - throw new InvalidOperationException("Engine returned null placements."); + throw new InvalidOperationException("Filler returned null placements."); return new PlateCandidate(context.MapPlacements(parts)); } diff --git a/OpenNest.Engine/Jobs/Placement/RemnantPlateNester.cs b/OpenNest.Engine/Jobs/Placement/RemnantPlateNester.cs new file mode 100644 index 0000000..9ba8106 --- /dev/null +++ b/OpenNest.Engine/Jobs/Placement/RemnantPlateNester.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq; +using System.Threading; + +using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Engine.Jobs.Placement.Fillers; +namespace OpenNest.Engine.Jobs.Placement; + +/// +/// Built-in remnant placement strategy for the whole-job runner. Fills one candidate per trial with +/// a for the vertical or horizontal remnant policy, on a fresh +/// private plate, using the same run-scoped identity, progress, and accounting boundaries as +/// . +/// +/// +/// The remnant fillers share the Default pipeline's automatic-rotation limitation, so non-automatic +/// requirements route through exactly as they do for Default. +/// +public sealed class RemnantPlateNester : IPlateNester +{ + private readonly Func fillerFactory; + private readonly OrderedPlateNester restrictedRotationNester = new(); + private readonly CandidatePlacementContext context = new(); + + internal RemnantPlateNester(Func fillerFactory) + { + this.fillerFactory = + fillerFactory ?? throw new ArgumentNullException(nameof(fillerFactory)); + } + + /// Vertical-remnant policy: minimize X-extent, prefer horizontal placement. + internal static RemnantPlateNester Vertical() => + new(plate => new RemnantPlateFiller(plate, RemnantFillPolicy.Vertical)); + + /// Horizontal-remnant policy: minimize Y-extent, prefer vertical placement. + internal static RemnantPlateNester Horizontal() => + new(plate => new RemnantPlateFiller(plate, RemnantFillPolicy.Horizontal)); + + public PlateCandidate Place( + PlatePlacementRequest request, + IProgress progress = null, + CancellationToken token = default + ) + { + ArgumentNullException.ThrowIfNull(request); + token.ThrowIfCancellationRequested(); + + // Same safety rule as DefaultPlateNester: the remnant fillers inherit the Default pipeline's + // automatic-rotation limitation, so any non-automatic requirement goes to the policy-aware + // ordered nester. + if (request.Parts.Any(part => part.Rotation.Kind != RotationPolicyKind.Automatic)) + return restrictedRotationNester.Place(request, progress, token); + + var plate = DrawingJobMapper.CreatePlate(request.Stock); + var items = context.CreateItems(request.Parts); + + var filler = + fillerFactory(plate) + ?? throw new InvalidOperationException("Filler factory returned null."); + var candidateProgress = CandidateProgressBridge.Create(progress, request.Stock.Id); + var parts = filler.Nest(items, candidateProgress, token); + token.ThrowIfCancellationRequested(); + if (parts == null) + throw new InvalidOperationException("Filler returned null placements."); + + return new PlateCandidate(context.MapPlacements(parts)); + } +} diff --git a/OpenNest.Engine/Jobs/Placement/StripPlateNester.cs b/OpenNest.Engine/Jobs/Placement/StripPlateNester.cs index 73b4ba3..356911c 100644 --- a/OpenNest.Engine/Jobs/Placement/StripPlateNester.cs +++ b/OpenNest.Engine/Jobs/Placement/StripPlateNester.cs @@ -2,32 +2,34 @@ using System; using System.Threading; using OpenNest.Engine.Jobs.Adapters; +using OpenNest.Engine.Jobs.Placement.Fillers; namespace OpenNest.Engine.Jobs.Placement; /// -/// Migrated built-in placement strategy for the whole-job runner. Reuses -/// iterative shrink-fill/pack geometry with the same run-scoped bookkeeping as : -/// remaining demand is read from the request and placement counts are derived from returned placements. +/// Built-in placement strategy for the whole-job runner. Runs the +/// iterative shrink-fill/pack geometry with the same run-scoped bookkeeping as +/// : remaining demand is read from the request and placement counts +/// are derived from returned placements. /// /// /// The identity/progress boundary mechanics live in : a private /// per requirement is created once per solve and reused across trials -/// (safe: the engine mutates per-trial and canonical copies, never the +/// (safe: the filler mutates per-trial and canonical copies, never the /// shared Drawing). Identity is by Drawing reference. Each trial gets a fresh private . /// public sealed class StripPlateNester : IPlateNester { - private readonly Func engineFactory; + private readonly Func fillerFactory; private readonly CandidatePlacementContext context = new(); public StripPlateNester() - : this(static plate => new StripNestEngine(plate)) { } + : this(static plate => new StripPlateFiller(plate)) { } - /// Injectable for tests; defaults to . - public StripPlateNester(Func engineFactory) + /// Injectable for tests; defaults to . + internal StripPlateNester(Func fillerFactory) { - this.engineFactory = - engineFactory ?? throw new ArgumentNullException(nameof(engineFactory)); + this.fillerFactory = + fillerFactory ?? throw new ArgumentNullException(nameof(fillerFactory)); } public PlateCandidate Place( @@ -42,14 +44,14 @@ public sealed class StripPlateNester : IPlateNester var plate = DrawingJobMapper.CreatePlate(request.Stock); var items = context.CreateItems(request.Parts); - var engine = - engineFactory(plate) - ?? throw new InvalidOperationException("Engine factory returned null."); + var filler = + fillerFactory(plate) + ?? throw new InvalidOperationException("Filler factory returned null."); var legacyProgress = CandidateProgressBridge.Create(progress, request.Stock.Id); - var parts = engine.Nest(items, legacyProgress, token); + var parts = filler.Nest(items, legacyProgress, token); token.ThrowIfCancellationRequested(); if (parts == null) - throw new InvalidOperationException("Engine returned null placements."); + throw new InvalidOperationException("Filler returned null placements."); return new PlateCandidate(context.MapPlacements(parts)); } diff --git a/OpenNest.Engine/Jobs/PlateNesterFactory.cs b/OpenNest.Engine/Jobs/PlateNesterFactory.cs index 841204a..5c3f702 100644 --- a/OpenNest.Engine/Jobs/PlateNesterFactory.cs +++ b/OpenNest.Engine/Jobs/PlateNesterFactory.cs @@ -1,14 +1,13 @@ using System; -using OpenNest.Engine.Jobs.Adapters; using OpenNest.Engine.Jobs.Placement; namespace OpenNest.Engine.Jobs; /// -/// Instance-scoped strategy resolution for the whole-job runner. Default and Strip resolve to the -/// migrated built-in plate nesters; the remnant strategies still use the legacy adapter during -/// rollout. The process-global NestEngineRegistry (including plugin registrations and -/// ActiveEngineName) is neither read nor modified. Unknown keys reject. +/// Instance-scoped strategy resolution for the whole-job runner. All four built-in strategies +/// resolve directly to filler-backed plate nesters. The process-global NestEngineRegistry +/// (including plugin registrations and ActiveEngineName) is neither read nor modified. +/// Unknown keys reject. /// public static class PlateNesterFactory { @@ -19,12 +18,8 @@ public static class PlateNesterFactory { "Default" => new DefaultPlateNester(), "Strip" => new StripPlateNester(), - "Vertical Remnant" => new LegacyPlateNesterAdapter(plate => new VerticalRemnantEngine( - plate - )), - "Horizontal Remnant" => new LegacyPlateNesterAdapter( - plate => new HorizontalRemnantEngine(plate) - ), + "Vertical Remnant" => RemnantPlateNester.Vertical(), + "Horizontal Remnant" => RemnantPlateNester.Horizontal(), _ => throw new NotSupportedException($"Unknown placement strategy: {strategy}."), }; } diff --git a/README.md b/README.md index 0fcd062..aa8e07b 100644 --- a/README.md +++ b/README.md @@ -102,13 +102,13 @@ The new whole-job contracts in `OpenNest.Engine/Jobs` (`namespace OpenNest`) use `NestJobRunner.Solve` allocates a job across physical sheets from the full stock inventory: every available stock entry is trialled independently each iteration, and only the winning candidate consumes a sheet or reduces demand. Selection is a documented deterministic greedy policy — lexicographic placed-count vector by ascending part priority, then lower consumed sheet area, then smaller placement envelope, then original stock input order (see `NestJobCandidateComparer`). It is a tie policy, not a guarantee of global-minimum material or plate count. Finite stock is never exceeded; `MaxPlates` caps sheet count; empty parts complete without consuming stock; empty or fully exhausted stock returns `Incomplete/StockExhausted`; a zero-placement candidate stops with `NoPlacementFound` and consumes no sheet. -`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. `PlateNesterFactory` resolves the built-in strategy names (`Default`, `Strip`, `Vertical Remnant`, `Horizontal Remnant`) to instance-scoped placement strategies; it neither reads nor changes the process-global `NestEngineRegistry`, and unknown keys reject. Quantity deduction in the engine paths the runner reaches (base-class fill/pack, strip deduction, remnant-fill ledger, shrink-leftover counting) is keyed by drawing reference, not display name, so same-name drawings and repeated requirements stay independent. `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. +`DrawingJobMapper` snapshots caller drawings/items under explicit requirement IDs. The built-in plate nesters create fresh private drawings, items, and plates for each trial through `CandidatePlacementContext` and map returned drawings **by reference**, never by name. Mutable legacy quantities never drive the fulfillment ledger. `PlateNesterFactory` resolves the built-in strategy names (`Default`, `Strip`, `Vertical Remnant`, `Horizontal Remnant`) to instance-scoped placement strategies; it neither reads nor changes the process-global `NestEngineRegistry`, and unknown keys reject. Quantity deduction in the engine paths the runner reaches (base-class fill/pack, strip deduction, remnant-fill ledger, shrink-leftover counting) is keyed by drawing reference, not display name, so same-name drawings and repeated requirements stay independent. `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 result = new NestJobRunner(PlateNesterFactory.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. @@ -116,7 +116,7 @@ var domainResult = NestResultMaterializer.Materialize(job, result); **Safety gate:** before the runner commits any candidate, `NestJobPlacementValidator` re-checks it against the immutable job geometry: closed usable contours, finite poses, the requirement's rotation policy (automatic / fixed / bounded sweep with step), containment inside the per-quadrant usable work area, hole-aware material overlap, and required part spacing (touching is allowed at zero spacing, rejected at positive spacing). Malformed engine output fails explicitly without consuming stock or demand. Cancellation throws `OperationCanceledException` before each trial and immediately after each engine return; no half-committed state is returned. An `Incomplete` result means the heuristic stopped, not that the geometry is impossible — the stop reason says why. Geometry snapshots preserve flat CNC rapid/line/arc programs, including origin and hole contours, without approximation; other instructions are explicitly rejected. -**Placement strategies:** `Default` and `Strip` are migrated built-ins (`OpenNest.Engine/Jobs/Placement/DefaultPlateNester.cs`, `StripPlateNester.cs`) that reuse the engine geometry while keeping demand read-only; the remnant strategies still run through `LegacyPlateNesterAdapter` during rollout. A runnable end-to-end example — multiple requirements, mixed finite/unlimited stock, full plate/leftover enumeration — lives in `OpenNest.Engine.Tests/Jobs/NestJobExampleTests.cs`. +**Placement strategies:** `Default`, `Strip`, `Vertical Remnant`, and `Horizontal Remnant` are filler-backed built-ins (`OpenNest.Engine/Jobs/Placement/DefaultPlateNester.cs`, `StripPlateNester.cs`, `RemnantPlateNester.cs`) that run the internal `Jobs/Placement/Fillers` geometry while keeping demand read-only. A runnable end-to-end example — multiple requirements, mixed finite/unlimited stock, full plate/leftover enumeration — lives in `OpenNest.Engine.Tests/Jobs/NestJobExampleTests.cs`. **Legacy caller boundaries (not yet migrated):** the desktop UI (`MainForm.RunAutoNestAsync` / `NestSinglePlateAsync`), the CLI (`OpenNest.Console`), and MCP (`NestingTools`) still call the old single-plate `engine.Nest(...)` entry points unchanged. UI adoption needs a separate adapter preserving populated-plate editing, preview routing, and Accept-versus-Cancel semantics. The public API (`OpenNest.Api`, `NestRunner.RunAsync`) already delegates to one `NestJobRunner.Solve` call and reports status, stop reason, part fulfillment, stock usage, and plate-to-stock mapping; `.nestquote` archives carry a schema version and round-trip incomplete jobs.