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}.")