diff --git a/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs b/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs
new file mode 100644
index 0000000..aa6058b
--- /dev/null
+++ b/OpenNest.Engine.Tests/Jobs/PlateNesterParityTests.cs
@@ -0,0 +1,257 @@
+using OpenNest.CNC;
+using OpenNest.Geometry;
+using Xunit;
+
+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.
+///
+public class PlateNesterParityTests
+{
+ private const double Tolerance = 1e-6;
+
+ private static readonly Size PlateSize = new(30, 50);
+ private static readonly Spacing Edge = new(1, 1, 1, 1);
+
+ private static NestJob Job(IReadOnlyList parts, int? stockQuantity = 3,
+ string strategy = "Default")
+ {
+ var stock = new NestPlateStock("stock", PlateSize, stockQuantity, 1, Edge);
+ return new NestJob(parts, new[] { stock }, new NestJobOptions(strategy));
+ }
+
+ private static NestJobResult Solve(IPlateNester nester, NestJob job) =>
+ new NestJobRunner(_ => nester).Solve(job);
+
+ 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);
+ return System.Math.Abs(delta) <= Tolerance ||
+ System.Math.Abs(System.Math.Abs(delta) - System.Math.PI * 2) <= Tolerance;
+ }
+
+ [Fact]
+ public void DefaultParity_Rectangles_SameFulfillmentAndLayout()
+ {
+ var parts = new[]
+ {
+ new NestJobPart("a", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)), 4),
+ new NestJobPart("b", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 3)), 3)
+ };
+
+ var legacy = Solve(new LegacyPlateNesterAdapter(plate => new DefaultNestEngine(plate)), Job(parts));
+ var migrated = 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);
+ }
+
+ [Fact]
+ public void StripParity_Rectangles_SameFulfillmentAndTotalCount()
+ {
+ var parts = new[]
+ {
+ new NestJobPart("a", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)), 4),
+ new NestJobPart("b", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 3)), 3)
+ };
+
+ var legacy = Solve(new LegacyPlateNesterAdapter(plate => new StripNestEngine(plate)),
+ Job(parts, strategy: "Strip"));
+ var migrated = 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.
+ }
+
+ [Fact]
+ public void MigratedBuiltins_AreResolvedByProductionFactory()
+ {
+ Assert.IsType(PlateNesterFactory.Create("Default"));
+ Assert.IsType(PlateNesterFactory.Create("Strip"));
+ Assert.IsType(PlateNesterFactory.Create("Vertical Remnant"));
+ Assert.IsType(PlateNesterFactory.Create("Horizontal Remnant"));
+ }
+
+ [Fact]
+ public void AsymmetricPart_ValidAndFulfilled()
+ {
+ // L-shape: 6x4 outer with a corner notch removed (single closed contour, asymmetric).
+ var lshape = new Program();
+ lshape.MoveTo(0, 0);
+ lshape.LineTo(6, 0);
+ lshape.LineTo(6, 4);
+ lshape.LineTo(3, 4);
+ lshape.LineTo(3, 2);
+ lshape.LineTo(0, 2);
+ lshape.LineTo(0, 0);
+
+ var parts = new[]
+ {
+ new NestJobPart("l", PartGeometrySnapshot.FromProgram(lshape), 3),
+ new NestJobPart("sq", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(4, 3)), 2)
+ };
+ var result = Solve(new DefaultPlateNester(), Job(parts));
+
+ Assert.Equal(NestJobStatus.Complete, result.Status);
+ Assert.Equal(3, ByPart(result)["l"].Placed);
+ Assert.Equal(2, ByPart(result)["sq"].Placed);
+ Assert.Equal(5, result.Plates.SelectMany(p => p.Placements).Count());
+ }
+
+ [Fact]
+ public void HoleAndArcParts_ValidAndFulfilled()
+ {
+ // 6x6 rectangle with a 2x2 inner hole (rapid contour), plus a D-shape with a semicircular arc.
+ var holed = new Program();
+ holed.MoveTo(0, 0);
+ holed.LineTo(6, 0);
+ holed.LineTo(6, 6);
+ holed.LineTo(0, 6);
+ holed.LineTo(0, 0);
+ holed.MoveTo(2, 2);
+ holed.LineTo(4, 2);
+ holed.LineTo(4, 4);
+ holed.LineTo(2, 4);
+ holed.LineTo(2, 2);
+
+ var arc = new Program();
+ arc.MoveTo(0, 0);
+ arc.LineTo(3, 0);
+ arc.ArcTo(3, 5, 3, 2.5, RotationType.CCW);
+ arc.LineTo(0, 5);
+ arc.LineTo(0, 0);
+
+ var parts = new[]
+ {
+ new NestJobPart("holed", PartGeometrySnapshot.FromProgram(holed), 2),
+ new NestJobPart("arc", PartGeometrySnapshot.FromProgram(arc), 2)
+ };
+ var result = Solve(new DefaultPlateNester(), Job(parts));
+
+ Assert.Equal(NestJobStatus.Complete, result.Status);
+ Assert.Equal(2, ByPart(result)["holed"].Placed);
+ Assert.Equal(2, ByPart(result)["arc"].Placed);
+ }
+
+ [Fact]
+ public void FixedRotation_Respected()
+ {
+ var part = new NestJobPart("fixed", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)),
+ 2, rotation: RotationPolicy.Fixed(0));
+ var result = Solve(new DefaultPlateNester(), Job(new[] { part }));
+
+ Assert.Equal(NestJobStatus.Complete, result.Status);
+ var placements = result.Plates.SelectMany(p => p.Placements).ToList();
+ Assert.Equal(2, placements.Count);
+ foreach (var placement in placements)
+ Assert.True(AnglesEqual(placement.Rotation, 0), $"fixed rotation violated: {placement.Rotation}");
+ }
+
+ [Fact]
+ public void RepeatedNames_KeepIndependentIdentity()
+ {
+ // Two distinct requirements sharing identical geometry (and, via the mapper, name) but different IDs.
+ var program = TestDrawingFactory.Rectangle(6, 4);
+ var parts = new[]
+ {
+ new NestJobPart("first", PartGeometrySnapshot.FromProgram(program), 2),
+ new NestJobPart("second", PartGeometrySnapshot.FromProgram(program), 1)
+ };
+ var result = Solve(new DefaultPlateNester(), Job(parts));
+
+ Assert.Equal(NestJobStatus.Complete, result.Status);
+ Assert.Equal(2, ByPart(result)["first"].Placed);
+ Assert.Equal(1, ByPart(result)["second"].Placed);
+ var ids = result.Plates.SelectMany(p => p.Placements).Select(p => p.PartId);
+ Assert.Equal(2, ids.Count(id => id == "first"));
+ Assert.Equal(1, ids.Count(id => id == "second"));
+ }
+
+ [Fact]
+ public void OffsetGeometry_ValidAndFulfilled()
+ {
+ // Part contour starting at a nonzero origin (offset geometry).
+ var program = new Program();
+ program.MoveTo(12, 7);
+ program.LineTo(18, 7);
+ program.LineTo(18, 11);
+ program.LineTo(12, 11);
+ program.LineTo(12, 7);
+
+ var part = new NestJobPart("offset", PartGeometrySnapshot.FromProgram(program), 2);
+ var result = Solve(new DefaultPlateNester(), Job(new[] { part }));
+
+ Assert.Equal(NestJobStatus.Complete, result.Status);
+ Assert.Equal(2, ByPart(result)["offset"].Placed);
+ // The runner's validator guarantees containment and non-overlap for every committed placement.
+ }
+
+ [Fact]
+ public void RunScopedCache_DrawingReusedAcrossTrials()
+ {
+ // 14x9 parts on 30x20: one sheet holds fewer than five, so the runner runs multiple candidate
+ // trials through the same nester instance. The run-scoped drawing cache must keep producing
+ // valid, correctly-attributed placements across trials.
+ var part = new NestJobPart("p", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(14, 9)), 5);
+ var stock = new NestPlateStock("stock", new Size(30, 20), 3);
+ var nester = new DefaultPlateNester();
+ var result = new NestJobRunner(_ => nester).Solve(new NestJob(new[] { part }, new[] { stock }));
+
+ Assert.Equal(NestJobStatus.Complete, result.Status);
+ Assert.Equal(5, result.Fulfillment.Single(f => f.PartId == "p").Placed);
+ Assert.Equal(2, result.Plates.Count);
+ var usage = result.StockUsage.Single();
+ Assert.Equal(2, usage.Used);
+ Assert.Equal(1, usage.Remaining);
+ }
+
+ [Fact]
+ public void LegacyRemnantStrategies_StillResolveThroughAdapter()
+ {
+ // Remnant strategies must keep working through the legacy adapter after the factory change.
+ var part = new NestJobPart("p", PartGeometrySnapshot.FromProgram(TestDrawingFactory.Rectangle(6, 4)), 2);
+ foreach (var strategy in new[] { "Vertical Remnant", "Horizontal Remnant" })
+ {
+ var result = Solve(PlateNesterFactory.Create(strategy), Job(new[] { part }, strategy: strategy));
+ Assert.Equal(NestJobStatus.Complete, result.Status);
+ Assert.Equal(2, result.Fulfillment.Single(f => f.PartId == "p").Placed);
+ }
+ }
+}
diff --git a/OpenNest.Engine/Jobs/CandidateProgressBridge.cs b/OpenNest.Engine/Jobs/CandidateProgressBridge.cs
new file mode 100644
index 0000000..9dd2c8d
--- /dev/null
+++ b/OpenNest.Engine/Jobs/CandidateProgressBridge.cs
@@ -0,0 +1,26 @@
+using System;
+
+namespace OpenNest;
+
+///
+/// Bridges legacy reporting into job progress while a candidate
+/// trial is being evaluated. Used by both the legacy adapter and the migrated built-in nesters so the
+/// stage/context mapping has one implementation.
+///
+internal static class CandidateProgressBridge
+{
+ internal static IProgress Create(IProgress progress, string stockId)
+ {
+ if (progress == null) return null;
+ return new LegacyToJob(progress, stockId);
+ }
+
+ private sealed class LegacyToJob(IProgress progress, string stockId) : IProgress
+ {
+ public void Report(NestProgress value)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ progress.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate, stockId, -1, 0, 0, value));
+ }
+ }
+}
diff --git a/OpenNest.Engine/Jobs/Placement/DefaultPlateNester.cs b/OpenNest.Engine/Jobs/Placement/DefaultPlateNester.cs
new file mode 100644
index 0000000..e184090
--- /dev/null
+++ b/OpenNest.Engine/Jobs/Placement/DefaultPlateNester.cs
@@ -0,0 +1,82 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+
+namespace OpenNest;
+
+///
+/// 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.
+///
+///
+/// A 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,
+/// 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 Dictionary drawingsById = new(StringComparer.Ordinal);
+ private readonly Dictionary idByDrawing = new(ReferenceEqualityComparer.Instance);
+
+ public DefaultPlateNester() : this(static plate => new DefaultNestEngine(plate))
+ {
+ }
+
+ /// Injectable for tests; defaults to .
+ public DefaultPlateNester(Func engineFactory)
+ {
+ this.engineFactory = engineFactory ?? throw new ArgumentNullException(nameof(engineFactory));
+ }
+
+ 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(request.Parts.Count);
+ foreach (var requirement in request.Parts)
+ {
+ if (!drawingsById.TryGetValue(requirement.Id, out var drawing))
+ {
+ drawing = DrawingJobMapper.CreateDrawing(requirement);
+ drawingsById.Add(requirement.Id, drawing);
+ idByDrawing.Add(drawing, requirement.Id);
+ }
+
+ // Quantity is the request's remaining demand; the engine may mutate this per-trial item,
+ // and that mutation is deliberately discarded — placement counts come from the result.
+ 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("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("Engine returned null placements.");
+
+ var placements = new List(parts.Count);
+ foreach (var part in parts)
+ {
+ if (part?.BaseDrawing == null || !idByDrawing.TryGetValue(part.BaseDrawing, out var id))
+ throw new InvalidOperationException("Placement does not reference a known 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/StripPlateNester.cs b/OpenNest.Engine/Jobs/Placement/StripPlateNester.cs
new file mode 100644
index 0000000..8c6060c
--- /dev/null
+++ b/OpenNest.Engine/Jobs/Placement/StripPlateNester.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+
+namespace OpenNest;
+
+///
+/// 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.
+///
+///
+/// A private per requirement is created once per solve and reused across trials
+/// (safe: the engine 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 Dictionary drawingsById = new(StringComparer.Ordinal);
+ private readonly Dictionary idByDrawing = new(ReferenceEqualityComparer.Instance);
+
+ public StripPlateNester() : this(static plate => new StripNestEngine(plate))
+ {
+ }
+
+ /// Injectable for tests; defaults to .
+ public StripPlateNester(Func engineFactory)
+ {
+ this.engineFactory = engineFactory ?? throw new ArgumentNullException(nameof(engineFactory));
+ }
+
+ 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(request.Parts.Count);
+ foreach (var requirement in request.Parts)
+ {
+ if (!drawingsById.TryGetValue(requirement.Id, out var drawing))
+ {
+ drawing = DrawingJobMapper.CreateDrawing(requirement);
+ drawingsById.Add(requirement.Id, drawing);
+ idByDrawing.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("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("Engine returned null placements.");
+
+ var placements = new List(parts.Count);
+ foreach (var part in parts)
+ {
+ if (part?.BaseDrawing == null || !idByDrawing.TryGetValue(part.BaseDrawing, out var id))
+ throw new InvalidOperationException("Placement does not reference a known 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/PlateNesterFactory.cs b/OpenNest.Engine/Jobs/PlateNesterFactory.cs
index c60b1be..495bfee 100644
--- a/OpenNest.Engine/Jobs/PlateNesterFactory.cs
+++ b/OpenNest.Engine/Jobs/PlateNesterFactory.cs
@@ -2,9 +2,10 @@ using System;
namespace OpenNest;
///
-/// Instance-scoped strategy resolution for the whole-job runner. The built-in strategies map
-/// to private engine factories; 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. 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.
///
public static class PlateNesterFactory
{
@@ -13,8 +14,8 @@ public static class PlateNesterFactory
ArgumentNullException.ThrowIfNull(strategy);
return strategy switch
{
- "Default" => new LegacyPlateNesterAdapter(plate => new DefaultNestEngine(plate)),
- "Strip" => new LegacyPlateNesterAdapter(plate => new StripNestEngine(plate)),
+ "Default" => new DefaultPlateNester(),
+ "Strip" => new StripPlateNester(),
"Vertical Remnant" => new LegacyPlateNesterAdapter(plate => new VerticalRemnantEngine(plate)),
"Horizontal Remnant" => new LegacyPlateNesterAdapter(plate => new HorizontalRemnantEngine(plate)),
_ => throw new NotSupportedException($"Unknown placement strategy: {strategy}.")