diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs index 9a273e84..81a54385 100644 --- a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelinePreviewTests.cs @@ -28,14 +28,15 @@ public class CutoutPipelinePreviewTests public void RegisteredEngineExpandsOnlyPhysicalOriginalDrawings(string engine) { var request = Request(engine); - var result = NestPipeline.RunCutoutPreview(request); + var result = NestPipeline.RunCutoutTrial(request); Assert.True(result.IsValid, string.Join("; ", result.Violations)); Assert.True(result.CanKeep); var plate = Assert.Single(result.Plates); Assert.Equal(2, plate.Parts.Count); - Assert.Same(request.Items[0].Drawing, plate.Parts[0].BaseDrawing); - Assert.Same(request.Items[1].Drawing, plate.Parts[1].BaseDrawing); - Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates[0].Placements.Select(p => p.PartId)); + // The insert precedes its frame so placed order cuts it before the frame's cutout. + Assert.Same(request.Items[1].Drawing, plate.Parts[0].BaseDrawing); + Assert.Same(request.Items[0].Drawing, plate.Parts[1].BaseDrawing); + Assert.Equal(new[] { "part-1", "part-0" }, result.Raw.Plates[0].Placements.Select(p => p.PartId)); Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 1, 0), (f.Requested, f.Placed, f.Unplaced))); Assert.Equal(0, request.Items[0].Drawing.Quantity.Nested); Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested); @@ -66,7 +67,7 @@ public class CutoutPipelinePreviewTests { var called = 0; var request = Request("stub"); - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { called++; var proxy = Assert.Single(job.Parts); @@ -81,24 +82,23 @@ public class CutoutPipelinePreviewTests } [Fact] - public void IncompleteEngineDoesNotBindReservedInsertEvenWithInvalidConsent() + public void UnplacedProxyLeavesItsReservedInsertAsUnplacedDemand() { var request = Request("stub"); - var result = NestPipeline.RunCutoutPreview(new Stub(job => Result(job)), "stub", request); - Assert.False(result.CanKeep); + var result = NestPipeline.RunCutoutTrial(new Stub(job => Result(job)), "stub", request); + Assert.True(result.CanKeep); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); Assert.Empty(result.Plates); - Assert.Contains(result.Violations, v => v.Contains("did not complete")); Assert.All(result.Raw.Fulfillment, f => Assert.Equal((1, 0, 1), (f.Requested, f.Placed, f.Unplaced))); Assert.Equal(0, request.Items[1].Drawing.Quantity.Nested); - AssertCannotCommit(result, request); } [Fact] public void ForgedFulfillmentOrDuplicateIndexDoesNotBindComposite() { var request = Request("stub"); - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { var id = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")).Id; var pose = new NestJobPlacement(id, 1, 11, 11, 0); @@ -115,7 +115,7 @@ public class CutoutPipelinePreviewTests var request = Request("stub"); request.Items[1].Quantity = 2; request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(26, 26), 1, 0.25) } }; - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); var residual = job.Parts.Single(p => p.Id == "part-1"); @@ -124,7 +124,7 @@ public class CutoutPipelinePreviewTests new NestJobPlacement(residual.Id, 0, 22, 1, 0)); }), "stub", request); Assert.True(result.IsValid, string.Join("; ", result.Violations)); - Assert.Equal(new[] { "part-0", "part-1", "part-1" }, + Assert.Equal(new[] { "part-1", "part-0", "part-1" }, result.Raw.Plates.Single().Placements.Select(p => p.PartId)); Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.Single().Placements .Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex)); @@ -136,7 +136,7 @@ public class CutoutPipelinePreviewTests public void UntrustedStockOverdrawOrRotationCannotBindAComposite() { var request = Request("stub"); - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, @@ -146,7 +146,7 @@ public class CutoutPipelinePreviewTests Assert.Empty(result.Plates); Assert.Contains(result.Violations, v => v.Contains("rotation constraint")); - var overdraw = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult( + var overdraw = NestPipeline.RunCutoutTrial(new Stub(job => new NestJobResult( NestJobStatus.Complete, NestJobStopReason.Completed, new[] { new NestJobPlateResult(0, job.Plates[0], new[] { new NestJobPlacement(job.Parts[0].Id, 0, 11, 11, 0) }), @@ -165,7 +165,7 @@ public class CutoutPipelinePreviewTests cts.Cancel(); var engine = new Stub(_ => throw new Exception("Must not call Solve")); Assert.Throws(() => - NestPipeline.RunCutoutPreview(engine, "stub", Request("stub"), token: cts.Token)); + NestPipeline.RunCutoutTrial(engine, "stub", Request("stub"), token: cts.Token)); } [Fact] @@ -173,7 +173,7 @@ public class CutoutPipelinePreviewTests { var request = Request("stub"); request.Items[1].Quantity = 2; - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0), @@ -210,8 +210,8 @@ public class CutoutPipelinePreviewTests { var request = Request("stub"); var progress = new CollectProgress(); - var result = NestPipeline.RunCutoutPreview(new ProgressStub(), "stub", request, progress); - Assert.False(result.CanKeep); + var result = NestPipeline.RunCutoutTrial(new ProgressStub(), "stub", request, progress); + Assert.Empty(result.Plates); Assert.Single(progress.Events); Assert.Equal(NestJobStage.EvaluatingCandidate, progress.Events[0].Stage); Assert.Equal(0, progress.Events[0].CommittedPlates); @@ -238,7 +238,7 @@ public class CutoutPipelinePreviewTests request.Items[1].Quantity = 3; request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6); request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) } }; - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); Assert.Equal(2, job.Parts.Single(p => p.Id == "part-1").Quantity); @@ -257,7 +257,7 @@ public class CutoutPipelinePreviewTests public void ForgedFulfillmentOrStockMetadataCannotBind(bool fulfillment) { var request = Request("stub"); - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); var good = Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0)); @@ -280,7 +280,7 @@ public class CutoutPipelinePreviewTests new NestItem { Drawing = initial.Items[1].Drawing, Quantity = 1 } }, Stock = new[] { new NestPlateStock("sheet", new Size(30, 30), 1, 0.25) } }; - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")); var residual = job.Parts.Where(p => p.Id != proxy.Id) @@ -289,9 +289,9 @@ public class CutoutPipelinePreviewTests .Concat(residual).ToArray()); }), "stub", request); Assert.True(result.IsValid, string.Join("; ", result.Violations)); - Assert.Equal(new[] { "part-0", "part-1", "part-2" }, + Assert.Equal(new[] { "part-1", "part-0", "part-2" }, result.Raw.Plates.Single().Placements.Select(p => p.PartId)); - Assert.Same(result.Plates.Single().Parts[1].BaseDrawing, + Assert.Same(result.Plates.Single().Parts[0].BaseDrawing, result.Plates.Single().Parts[2].BaseDrawing); } @@ -308,7 +308,7 @@ public class CutoutPipelinePreviewTests Options = new NestJobOptions(maxPlates: 2) }; var calls = 0; - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { calls++; var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray(); @@ -322,7 +322,7 @@ public class CutoutPipelinePreviewTests Assert.Equal(1, calls); Assert.True(result.IsValid, string.Join("; ", result.Violations)); Assert.Equal(2, result.Raw.Plates.Count); - Assert.All(result.Raw.Plates, plate => Assert.Equal(new[] { "part-0", "part-1" }, + Assert.All(result.Raw.Plates, plate => Assert.Equal(new[] { "part-1", "part-0" }, plate.Placements.Select(p => p.PartId))); Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 2, 0), (f.Requested, f.Placed, f.Unplaced))); @@ -331,7 +331,7 @@ public class CutoutPipelinePreviewTests } [Fact] - public void UnplacedFrameReleasesInsertDemandWithoutABindablePartialProposal() + public void UnplacedFrameReleasesInsertDemandAndKeepsTheValidPartialProposal() { var request = Request("stub"); request.Items[0].Quantity = 2; @@ -339,7 +339,7 @@ public class CutoutPipelinePreviewTests request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6); request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) } }; var calls = 0; - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { calls++; var first = job.Parts.Single(p => p.Id.EndsWith("-0")); @@ -348,13 +348,13 @@ public class CutoutPipelinePreviewTests return builder.Build(NestJobStopReason.NoPlacementFound); }), "stub", request); Assert.Equal(1, calls); - Assert.False(result.CanKeep); - Assert.Empty(result.Plates); + Assert.True(result.CanKeep); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(2, Assert.Single(result.Plates).Parts.Count); Assert.Equal(NestJobStatus.Incomplete, result.Raw.Status); Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 1, 1), (f.Requested, f.Placed, f.Unplaced))); - Assert.Equal(new[] { "part-0", "part-1" }, result.Raw.Plates.Single().Placements.Select(p => p.PartId)); - AssertCannotCommit(result, request); + Assert.Equal(new[] { "part-1", "part-0" }, result.Raw.Plates.Single().Placements.Select(p => p.PartId)); } [Fact] @@ -370,7 +370,7 @@ public class CutoutPipelinePreviewTests new NestItem { Drawing = frame2, Quantity = 1 }, initial.Items[1] }, Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) } }; - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray(); Assert.Equal(2, proxies.Length); @@ -381,7 +381,7 @@ public class CutoutPipelinePreviewTests return builder.Build(NestJobStopReason.Completed); }), "stub", request); Assert.True(result.IsValid, string.Join("; ", result.Violations)); - Assert.Equal(new[] { "part-0", "part-2", "part-1", "part-2" }, + Assert.Equal(new[] { "part-2", "part-0", "part-2", "part-1" }, result.Raw.Plates.SelectMany(p => p.Placements).Select(p => p.PartId)); Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements) .Where(p => p.PartId == "part-2").Select(p => p.InstanceIndex)); @@ -403,7 +403,7 @@ public class CutoutPipelinePreviewTests new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1, StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter } }, new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) }); - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { var proxy = Assert.Single(job.Parts); Assert.True(proxy.Rotation.Allows(quarter)); @@ -412,11 +412,11 @@ public class CutoutPipelinePreviewTests }), "stub", request); Assert.True(result.IsValid, string.Join("; ", result.Violations)); var poses = result.Raw.Plates.Single().Placements; - Assert.Equal(quarter, poses[0].Rotation, 8); Assert.Equal(quarter, poses[1].Rotation, 8); + Assert.Equal(quarter, poses[0].Rotation, 8); // Independent square-hole oracle, not the production clearance predicate. - var localX = poses[1].Y - poses[0].Y; - var localY = -(poses[1].X - poses[0].X); + var localX = poses[0].Y - poses[1].Y; + var localY = -(poses[0].X - poses[1].X); foreach (var dx in new[] { 0.0, 3.0 }) foreach (var dy in new[] { 0.0, 3.0 }) { @@ -434,7 +434,7 @@ public class CutoutPipelinePreviewTests new NestPlateStock("tight", new Size(22, 22), 1, 0.25), new NestPlateStock("wide", new Size(22, 22), 1, 0.6, quadrant: 3) } }; - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { var proxy = Assert.Single(job.Parts); var builder = new NestJobResultBuilder(job); @@ -461,10 +461,10 @@ public class CutoutPipelinePreviewTests })); try { - var result = NestPipeline.RunCutoutPreview(Request(name)); + var result = NestPipeline.RunCutoutTrial(Request(name)); Assert.Equal(1, calls); Assert.True(result.IsValid, string.Join("; ", result.Violations)); - Assert.Equal(new[] { "part-0", "part-1" }, + Assert.Equal(new[] { "part-1", "part-0" }, result.Raw.Plates.Single().Placements.Select(p => p.PartId)); } finally @@ -492,13 +492,13 @@ public class CutoutPipelinePreviewTests new NestItem { Drawing = frame, Quantity = 1 }, new NestItem { Drawing = insert, Quantity = 2 } }, new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) }); - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { var proxy = Assert.Single(job.Parts); return Result(job, new NestJobPlacement(proxy.Id, 0, 1, 1, 0)); }), "stub", request); Assert.True(result.IsValid, string.Join("; ", result.Violations)); - var inserts = result.Raw.Plates.Single().Placements.Skip(1).ToArray(); + var inserts = result.Raw.Plates.Single().Placements.Take(2).ToArray(); Assert.Equal(2, inserts.Length); Assert.All(inserts, p => { @@ -515,7 +515,7 @@ public class CutoutPipelinePreviewTests { var request = Request("stub"); request.Items[1].Priority = 3; - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-")); return Result(job); @@ -538,13 +538,13 @@ public class CutoutPipelinePreviewTests new Spacing(0.5, 0.5), quadrant) }, Options = new NestJobOptions(maxPlates: 1) }; - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, x, y, 0))), "stub", request); Assert.True(result.IsValid, string.Join("; ", result.Violations)); Assert.Equal(quadrant, result.Plates.Single().Stock.Quadrant); - Assert.Equal((x, y), (result.Raw.Plates.Single().Placements[0].X, - result.Raw.Plates.Single().Placements[0].Y)); + Assert.Equal((x, y), (result.Raw.Plates.Single().Placements.Last().X, + result.Raw.Plates.Single().Placements.Last().Y)); } [Fact] @@ -552,7 +552,7 @@ public class CutoutPipelinePreviewTests { var request = Request("stub"); using var cts = new CancellationTokenSource(); - Assert.Throws(() => NestPipeline.RunCutoutPreview( + Assert.Throws(() => NestPipeline.RunCutoutTrial( new Stub(job => { var proxy = Assert.Single(job.Parts); @@ -566,7 +566,7 @@ public class CutoutPipelinePreviewTests public void CompleteStatusWithMissingProxyIsUntrustedAndNotBindable() { var request = Request("stub"); - var result = NestPipeline.RunCutoutPreview(new Stub(job => new NestJobResult( + var result = NestPipeline.RunCutoutTrial(new Stub(job => new NestJobResult( NestJobStatus.Complete, NestJobStopReason.Completed, Array.Empty(), job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity, 0, p.Quantity)), @@ -582,7 +582,7 @@ public class CutoutPipelinePreviewTests { var request = Request("stub"); var progress = new CollectProgress(); - var result = NestPipeline.RunCutoutPreview(new ReportingStub(), "stub", request, progress); + var result = NestPipeline.RunCutoutTrial(new ReportingStub(), "stub", request, progress); Assert.True(result.IsValid, string.Join("; ", result.Violations)); Assert.Equal(new[] { NestJobStage.EvaluatingCandidate, NestJobStage.PlateCommitted }, progress.Events.Select(e => e.Stage)); @@ -617,11 +617,11 @@ public class CutoutPipelinePreviewTests new NestItem { Drawing = new Drawing("long-frame", program), Quantity = 1 }, new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1 } }, new[] { new NestPlateStock("narrow", new Size(22, 12), 1, 0.25) }); - var result = NestPipeline.RunCutoutPreview(request); + var result = NestPipeline.RunCutoutTrial(request); Assert.True(result.IsValid, string.Join("; ", result.Violations)); - var frame = result.Raw.Plates.Single().Placements[0]; + var frame = result.Raw.Plates.Single().Placements.Last(); Assert.Equal(System.Math.PI / 2, frame.Rotation, 7); - Assert.Equal(new[] { "part-0", "part-1" }, + Assert.Equal(new[] { "part-1", "part-0" }, result.Raw.Plates.Single().Placements.Select(p => p.PartId)); } @@ -629,7 +629,7 @@ public class CutoutPipelinePreviewTests public void ChangedLiveInsertDuringSolveNeverBindsUncheckedComposite() { var request = Request("stub"); - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { request.Items[1].Drawing.Program = TestShapes.Rectangle(40, 40); var proxy = Assert.Single(job.Parts); @@ -655,7 +655,7 @@ public class CutoutPipelinePreviewTests Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) }, Options = options }; - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { Assert.Same(options, job.Options); Assert.Equal("Strip", job.Options.PlacementStrategy); @@ -687,14 +687,14 @@ public class CutoutPipelinePreviewTests new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(3, 3)), Quantity = 1, StepAngle = 1, RotationStart = angle, RotationEnd = angle } }, new[] { new NestPlateStock("sheet", new Size(40, 40), 1, 0.25) }); - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => Result(job, new NestJobPlacement(Assert.Single(job.Parts).Id, 0, 15, 5, angle))), "stub", request); Assert.True(result.IsValid, string.Join("; ", result.Violations)); var poses = result.Raw.Plates.Single().Placements; - Assert.Equal(angle, poses[1].Rotation, 8); - var dx = poses[1].X - poses[0].X; - var dy = poses[1].Y - poses[0].Y; + Assert.Equal(angle, poses[0].Rotation, 8); + var dx = poses[0].X - poses[1].X; + var dy = poses[0].Y - poses[1].Y; var cosine = System.Math.Cos(angle); var sine = System.Math.Sin(angle); var localX = dx * cosine + dy * sine; @@ -722,28 +722,15 @@ public class CutoutPipelinePreviewTests new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(6, 6)), Quantity = 40, StepAngle = 1, RotationStart = 0, RotationEnd = 0 } }, new[] { new NestPlateStock("sheet", new Size(22, 22), 40, 0.25) }); - var result = NestPipeline.RunCutoutPreview(new Stub(job => + var result = NestPipeline.RunCutoutTrial(new Stub(job => { Assert.Equal(32, job.Parts.Count(p => p.Id.StartsWith("__cutout-proxy-"))); Assert.Equal(8, job.Parts.Single(p => p.Id == "part-0").Quantity); Assert.Equal(8, job.Parts.Single(p => p.Id == "part-1").Quantity); return Result(job); }), "stub", request); - Assert.False(result.CanKeep); + Assert.Empty(result.Plates); Assert.All(result.Raw.Fulfillment, f => Assert.Equal((40, 0, 40), (f.Requested, f.Placed, f.Unplaced))); } - - [Fact] - public void OrdinaryPipelineIsStillUnchanged() - { - var request = Request("stub"); - var result = NestPipeline.Run(new Stub(job => - { - Assert.DoesNotContain(job.Parts, p => p.Id.StartsWith("__cutout-proxy-")); - return Result(job); - }), "stub", request); - Assert.True(result.CanKeep); - Assert.Empty(result.Plates); - } } diff --git a/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelineRunTests.cs b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelineRunTests.cs new file mode 100644 index 00000000..60780738 --- /dev/null +++ b/OpenNest.Engine.Tests/Jobs/Cutouts/CutoutPipelineRunTests.cs @@ -0,0 +1,135 @@ +using OpenNest.Engine.Jobs; +using OpenNest.Geometry; +using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes; + +namespace OpenNest.Engine.Tests.Jobs.Cutouts; + +/// Ordinary whole-job runs bundle parts into other parts' cutouts first, and fall back +/// to the ordinary solve whenever that bundled trial is refused or places less. +[Collection("Cutout pipeline registry")] +public class CutoutPipelineRunTests +{ + private static NestPipelineRequest Request(string engine, int sheets = 1) => + new(engine, + new[] { new NestItem { Drawing = new Drawing("frame", TestShapes.Ring(20, 10)), Quantity = 1 }, + new NestItem { Drawing = new Drawing("insert", TestShapes.Rectangle(6, 6)), Quantity = 1 } }, + new[] { new NestPlateStock("sheet", new Size(22, 22), sheets, 0.25) }, + new NestJobOptions(maxPlates: sheets)); + + private sealed class Stub(Func solve) : INestingEngine + { + public List Jobs { get; } = new(); + + public NestJobResult Solve(NestJob job, IProgress? progress = null, + CancellationToken token = default) + { + Jobs.Add(job); + return solve(job); + } + } + + private static bool IsTrial(NestJob job) => job.Parts.Any(p => p.Id.StartsWith("__cutout-proxy-")); + + private static NestJobResult Place(NestJob job, params (string Id, double X, double Y)[] poses) + { + var builder = new NestJobResultBuilder(job); + if (poses.Length > 0) + builder.AddSheet(job.Plates[0], poses.Select(p => (p.Id, p.X, p.Y, 0.0))); + var complete = job.Parts.All(p => poses.Count(x => x.Id == p.Id) == p.Quantity); + return builder.Build(complete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound); + } + + [Theory] + [InlineData("Irregular")] + [InlineData("Default")] + public void OrdinaryRunPlacesAnInsertInsideItsFrameThatTheOrdinarySolveCannotFit(string engine) + { + var request = Request(engine); + var baseline = NestPipeline.RunWithoutCutouts(request); + Assert.Equal(NestJobStatus.Incomplete, baseline.Status); + + var result = NestPipeline.Run(request); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Empty(result.RefusedCutoutTrial); + var plate = Assert.Single(result.Plates); + Assert.Equal(new[] { request.Items[1].Drawing, request.Items[0].Drawing }, + plate.Parts.Select(p => p.BaseDrawing)); + } + + [Fact] + public void RefusedTrialFallsBackToTheOrdinarySolveAndSaysWhy() + { + var request = Request("stub"); + var engine = new Stub(job => IsTrial(job) + // Forged: the proxy is placed with a quarter turn its fixed rotation forbids. + ? new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed, + new[] { new NestJobPlateResult(0, job.Plates[0], new[] { + new NestJobPlacement(job.Parts[0].Id, 0, 11, 11, System.Math.PI / 2) }) }, + job.Parts.Select(p => new PartFulfillment(p.Id, 1, 1, 0)), + new[] { new StockUsage("sheet", 1, 0) }) + : Place(job, ("part-0", 11, 11))); + var result = NestPipeline.Run(engine, "stub", request); + Assert.Equal(2, engine.Jobs.Count); + Assert.True(IsTrial(engine.Jobs[0])); + Assert.False(IsTrial(engine.Jobs[1])); + Assert.True(result.CanKeep); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Contains(result.RefusedCutoutTrial, v => v.Contains("rotation constraint")); + Assert.Equal(new[] { "part-0" }, Assert.Single(result.Raw.Plates).Placements.Select(p => p.PartId)); + } + + [Fact] + public void IncompleteTrialYieldsToAnOrdinarySolveThatPlacesMore() + { + var request = Request("stub"); + request.Items[1].Quantity = 2; + request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(22, 40), 1, 0.25) } }; + // The trial leaves its proxy (and the reserved insert) unplaced; the ordinary solve does not. + var engine = new Stub(job => IsTrial(job) + ? Place(job, ("part-1", 30, 1)) + : Place(job, ("part-0", 11, 11), ("part-1", 23, 1), ("part-1", 30, 1))); + var result = NestPipeline.Run(engine, "stub", request); + Assert.Equal(2, engine.Jobs.Count); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(NestJobStatus.Complete, result.Status); + Assert.Empty(result.RefusedCutoutTrial); + Assert.Equal(3, Assert.Single(result.Plates).Parts.Count); + } + + [Fact] + public void IncompleteTrialThatPlacesAtLeastAsMuchIsKept() + { + var request = Request("stub"); + request.Items[0].Quantity = 2; + request.Items[1].Quantity = 2; + request = request with { Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) } }; + var engine = new Stub(job => IsTrial(job) + ? Place(job, (job.Parts.First(IsProxy).Id, 11, 11)) + : Place(job, ("part-0", 11, 11))); + var result = NestPipeline.Run(engine, "stub", request); + Assert.Equal(2, engine.Jobs.Count); + Assert.True(result.CanKeep); + Assert.True(result.IsValid, string.Join("; ", result.Violations)); + Assert.Equal(NestJobStatus.Incomplete, result.Status); + Assert.Equal(new[] { "part-1", "part-0" }, + Assert.Single(result.Raw.Plates).Placements.Select(p => p.PartId)); + Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 1, 1), (f.Requested, f.Placed, f.Unplaced))); + + static bool IsProxy(NestJobPart part) => part.Id.StartsWith("__cutout-proxy-"); + } + + [Fact] + public void StopDuringTheTrialNeverStartsTheOrdinarySolve() + { + using var cts = new CancellationTokenSource(); + var engine = new Stub(job => + { + cts.Cancel(); + return Place(job); + }); + Assert.Throws(() => + NestPipeline.Run(engine, "stub", Request("stub"), token: cts.Token)); + Assert.True(IsTrial(Assert.Single(engine.Jobs))); + } +} diff --git a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs index f90fc48c..f140ea14 100644 --- a/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs +++ b/OpenNest.Engine/Jobs/Cutouts/CutoutPipelinePrepass.cs @@ -7,7 +7,9 @@ using OpenNest.Engine.Jobs.Adapters; namespace OpenNest.Engine.Jobs.Cutouts; -/// Internal-only frame bundles. No normal pipeline caller enables this. +/// Frame bundles prepared by before every whole-job solve: +/// engines place a frame proxy and never see its reserved inserts. Only actually placed +/// proxies expand, and the expanded physical layout is validated again. internal sealed class CutoutPipelinePrepass { // A job with thousands of repeats still offers the residual as ordinary demand; @@ -141,7 +143,8 @@ internal sealed class CutoutPipelinePrepass } /// Reject untrusted accounting before expansion. A failed trial is never bindable, - /// including with allowInvalid. Only actually placed proxies consume reserved inserts. + /// including with allowInvalid. Only actually placed proxies consume reserved inserts; + /// an unplaced proxy's inserts stay unplaced demand of their own requirements. internal bool TryExpand(NestJob original, NestJobResult result, CancellationToken token, out NestJobResult? expanded, out string failure) @@ -215,7 +218,8 @@ internal sealed class CutoutPipelinePrepass poses.Add((pose.PartId, pose.X, pose.Y, pose.Rotation)); continue; } - poses.Add((bundle.FrameId, pose.X, pose.Y, pose.Rotation)); + // Inserts precede their frame: cutting the frame's cutout releases the slug + // each insert is cut from, so placed order is the safe cutting order. var sine = System.Math.Sin(pose.Rotation); var cosine = System.Math.Cos(pose.Rotation); foreach (var local in bundle.LocalInserts) @@ -223,6 +227,7 @@ internal sealed class CutoutPipelinePrepass pose.X + local.X * cosine - local.Y * sine, pose.Y + local.X * sine + local.Y * cosine, pose.Rotation + local.Rotation)); + poses.Add((bundle.FrameId, pose.X, pose.Y, pose.Rotation)); } // Canonical stock reference, not an engine-supplied object with altered settings. var stock = original.Plates.Single(s => s.Id == sheet.StockId); diff --git a/OpenNest.Engine/Jobs/NestPipeline.cs b/OpenNest.Engine/Jobs/NestPipeline.cs index 0863533c..b812fe11 100644 --- a/OpenNest.Engine/Jobs/NestPipeline.cs +++ b/OpenNest.Engine/Jobs/NestPipeline.cs @@ -75,6 +75,16 @@ public sealed class NestPipelineResult public TimeSpan SolveTime { get; } public TimeSpan ValidationTime { get; } internal IReadOnlyDictionary DrawingsByPartId { get; } + + /// Why a bundled cutout trial was refused before this ordinary solve replaced it; + /// empty when no trial ran or the trial was not refused. Never part of : + /// the refused trial has no bindable plates. + public IReadOnlyList RefusedCutoutTrial { get; private init; } = Array.Empty(); + + internal NestPipelineResult WithRefusedCutoutTrial(IReadOnlyList refusals) => + new(EngineName, Job, Raw, Plates, Violations, CanKeep, SolveTime, ValidationTime, + DrawingsByPartId) + { RefusedCutoutTrial = refusals }; } /// @@ -108,39 +118,85 @@ public static class NestPipeline return Run(engine, request.EngineName, request, progress, token); } - /// Runs a caller-supplied engine instance through the same validation and binding. + /// Runs a caller-supplied engine instance through the same validation and binding. + /// Parts that fit other parts' enclosed cutouts are first bundled into their frames + /// (engine-agnostic); see . The engine then solves once. + /// A bundled trial that fails physical validation, or leaves demand unplaced, is compared + /// with an ordinary solve of the same job and the better valid proposal is returned. public static NestPipelineResult Run( INestingEngine engine, string engineName, NestPipelineRequest request, IProgress progress = null, CancellationToken token = default - ) => RunCore(engine, engineName, request, progress, token, cutoutPreview: false); + ) => Run(engine, engineName, request, progress, token, cutouts: true); - /// Internal test-only integration trial. Normal Run never prepares cutouts; - /// this entry point must not be called by production front ends before cutting/post gates. - internal static NestPipelineResult RunCutoutPreview( + /// The ordinary solve alone, with no cutout pre-pass: a characterization baseline + /// for tests and benchmarks, not a front-end setting. + internal static NestPipelineResult RunWithoutCutouts( + NestPipelineRequest request, + IProgress progress = null, + CancellationToken token = default + ) => Run(NestingEngineRegistry.Create(request.EngineName), request.EngineName, request, + progress, token, cutouts: false); + + /// The bundled cutout trial alone, without the ordinary-solve comparison, so tests + /// can exercise its validation boundary. Jobs without bundles run the ordinary solve. + internal static NestPipelineResult RunCutoutTrial( INestingEngine engine, string engineName, NestPipelineRequest request, IProgress progress = null, CancellationToken token = default - ) => RunCore(engine, engineName, request, progress, token, cutoutPreview: true); + ) + { + var (job, drawingsByPartId) = CreateJob(engine, request, token); + var prepass = CutoutPipelinePrepass.Prepare(job, token); + var result = Evaluate(engine, engineName, job, prepass, drawingsByPartId, progress, token); + return prepass != null && result.CanKeep && result.IsValid + ? Committed(result, progress, token) : result; + } - internal static NestPipelineResult RunCutoutPreview( + internal static NestPipelineResult RunCutoutTrial( NestPipelineRequest request, IProgress progress = null, CancellationToken token = default - ) => RunCutoutPreview(NestingEngineRegistry.Create(request.EngineName), + ) => RunCutoutTrial(NestingEngineRegistry.Create(request.EngineName), request.EngineName, request, progress, token); - private static NestPipelineResult RunCore( + private static NestPipelineResult Run( INestingEngine engine, string engineName, NestPipelineRequest request, IProgress progress, CancellationToken token, - bool cutoutPreview + bool cutouts + ) + { + var (job, drawingsByPartId) = CreateJob(engine, request, token); + var prepass = cutouts ? CutoutPipelinePrepass.Prepare(job, token) : null; + if (prepass == null) + return Evaluate(engine, engineName, job, null, drawingsByPartId, progress, token); + + var trial = Evaluate(engine, engineName, job, prepass, drawingsByPartId, progress, token); + var trialKeepable = trial.CanKeep && trial.IsValid; + if (trialKeepable && trial.Status == NestJobStatus.Complete) + return Committed(trial, progress, token); + + // Unplaced frames return their reserved inserts to unplaced demand, and an untrusted + // composite is never bindable; the ordinary solve is the established baseline. + var plain = Evaluate(engine, engineName, job, null, drawingsByPartId, progress, token); + if (!trialKeepable) + return plain.WithRefusedCutoutTrial(trial.Violations); + if (plain.CanKeep && plain.IsValid && Placed(plain) > Placed(trial)) + return plain; + return Committed(trial, progress, token); + } + + private static (NestJob Job, Dictionary Drawings) CreateJob( + INestingEngine engine, + NestPipelineRequest request, + CancellationToken token ) { ArgumentNullException.ThrowIfNull(engine); @@ -163,7 +219,37 @@ public static class NestPipeline var job = new NestJob(parts, request.Stock, request.Options); NestJobValidator.Validate(job); - var prepass = cutoutPreview ? CutoutPipelinePrepass.Prepare(job, token) : null; + return (job, drawingsByPartId); + } + + private static int Placed(NestPipelineResult result) => + result.Raw.Plates.Sum(p => p.Placements.Count); + + // Trial progress hid the engine's proxy commit counts; report validated physical ones. + private static NestPipelineResult Committed(NestPipelineResult result, + IProgress progress, CancellationToken token) + { + var count = 0; + for (var i = 0; i < result.Raw.Plates.Count; i++) + { + count += result.Raw.Plates[i].Placements.Count; + progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, + result.Raw.Plates[i].StockId, i, i + 1, count)); + } + token.ThrowIfCancellationRequested(); + return result; + } + + private static NestPipelineResult Evaluate( + INestingEngine engine, + string engineName, + NestJob job, + CutoutPipelinePrepass prepass, + IReadOnlyDictionary drawingsByPartId, + IProgress progress, + CancellationToken token + ) + { var engineJob = prepass?.EngineJob ?? job; if (prepass != null) NestJobValidator.Validate(engineJob); @@ -176,8 +262,8 @@ public static class NestPipeline var solveTime = clock.Elapsed; token.ThrowIfCancellationRequested(); - // D8/C10: only the main (non-cutout-preview) path offers last-resort clamp removal. - // No-op whenever nothing is blocked, so an ordinary solve is unaffected (C10). + // D8/C10: only the ordinary solve offers last-resort clamp removal; an incomplete + // cutout trial is compared with that solve instead. No-op whenever nothing is blocked. if (prepass == null) { var recovered = ClampRemovalRecovery.TryRecover(job, raw, engine, token); @@ -208,8 +294,6 @@ public static class NestPipeline { raw = expanded; violations.AddRange(NestLayoutCheck.Violations(job, raw, names, out var physicalKeep)); - if (raw.Status != NestJobStatus.Complete) - violations.Add("Cutout engine did not complete the transformed job; unplaced frames' reserved inserts remain unplaced in the original job"); canKeep = physicalKeep && violations.Count == 0; } else @@ -253,17 +337,6 @@ public static class NestPipeline } } - token.ThrowIfCancellationRequested(); - if (prepass != null && canKeep) - { - var count = 0; - for (var i = 0; i < raw.Plates.Count; i++) - { - count += raw.Plates[i].Placements.Count; - progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted, - raw.Plates[i].StockId, i, i + 1, count)); - } - } token.ThrowIfCancellationRequested(); return new NestPipelineResult( engineName, diff --git a/OpenNest.Tests/Engine/CutoutCuttingSafetyTests.cs b/OpenNest.Tests/Engine/CutoutCuttingSafetyTests.cs index 9aa8b22c..ab1e36d1 100644 --- a/OpenNest.Tests/Engine/CutoutCuttingSafetyTests.cs +++ b/OpenNest.Tests/Engine/CutoutCuttingSafetyTests.cs @@ -10,14 +10,14 @@ using OpenNest.Tests.CuttingPlanning; namespace OpenNest.Tests.Engine; /// -/// Cutouts-first placements are only cut-safe once the insert is cut before its host and the -/// host's hole leads clear it. Posting review must say so on the committed parts, and a fresh -/// Plan Cutting run on those actual parts must produce that order. +/// Cutouts-first placements are only cut-safe while the insert is cut before its host and the +/// host's hole leads clear it. Auto Nest commits inserts first; posting review must warn once +/// that order is lost, and a fresh Plan Cutting run on those actual parts must restore it. /// public class CutoutCuttingSafetyTests { [Fact] - public void CommittedCutoutPreview_WarnsUntilPlanCuttingCutsTheInsertFirst() + public void CommittedCutoutNest_CutsTheInsertFirstAndWarnsWhenReordered() { var frame = new OpenNest.Shapes.RingShape { OuterDiameter = 20, InnerDiameter = 10 }.GetDrawing(); frame.Name = "neutral-frame"; @@ -33,7 +33,8 @@ public class CutoutCuttingSafetyTests new NestItem { Drawing = insert, Quantity = 1 } }, new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) }, new NestJobOptions(maxPlates: 1)); - var preview = NestPipeline.RunCutoutPreview(request); + var preview = NestPipeline.Run(request); + Assert.Empty(preview.RefusedCutoutTrial); Assert.True(preview.IsValid, string.Join("; ", preview.Violations)); var nest = new Nest(); nest.Drawings.Add(frame); @@ -41,11 +42,15 @@ public class CutoutCuttingSafetyTests using (var manager = new PlateManager(nest)) NestPipelineCommit.ApplyToEmptyPlates(preview, manager); var plate = Assert.Single(nest.Plates, p => p.Parts.Count != 0); - Assert.Equal(new[] { frame, insert }, plate.Parts.Select(p => p.BaseDrawing)); - var host = plate.Parts[0]; - var inner = plate.Parts[1]; + Assert.Equal(new[] { insert, frame }, plate.Parts.Select(p => p.BaseDrawing)); + var inner = plate.Parts[0]; + var host = plate.Parts[1]; + var committed = PostVerificationAnalyzer.Analyze(nest); + Assert.DoesNotContain(committed.Findings, f => f.Kind is PostVerificationKind.NestedCutOrder + or PostVerificationKind.NestedLead or PostVerificationKind.Incomplete); - // As committed, the frame (and so its cutout) is cut before the insert inside it. + // Reordered (for example by hand), the frame's cutout is cut before the insert inside it. + plate.Parts.Reorder(new[] { host, inner }); var before = PostVerificationAnalyzer.Analyze(nest); var order = Assert.Single(before.Findings, f => f.Kind == PostVerificationKind.NestedCutOrder); Assert.Equal((2, 1), (order.PartNumber, order.OtherPartNumber)); diff --git a/OpenNest.Tests/Engine/CutoutPipelinePersistenceTests.cs b/OpenNest.Tests/Engine/CutoutPipelinePersistenceTests.cs index 5886a547..cd80f35a 100644 --- a/OpenNest.Tests/Engine/CutoutPipelinePersistenceTests.cs +++ b/OpenNest.Tests/Engine/CutoutPipelinePersistenceTests.cs @@ -26,12 +26,13 @@ public class CutoutPipelinePersistenceTests new NestItem { Drawing = insert, Quantity = 2 } }, new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) }, new NestJobOptions(maxPlates: 2)); - var ordinary = NestPipeline.Run(request); + var ordinary = NestPipeline.RunWithoutCutouts(request); Assert.Contains(ordinary.Raw.Fulfillment, f => f.Unplaced > 0); - var result = NestPipeline.RunCutoutPreview(request); + var result = NestPipeline.Run(request); + Assert.Empty(result.RefusedCutoutTrial); Assert.True(result.IsValid, string.Join("; ", result.Violations)); Assert.Equal(2, result.Raw.Plates.Count); - Assert.All(result.Raw.Plates, p => Assert.Equal(new[] { "part-0", "part-1" }, + Assert.All(result.Raw.Plates, p => Assert.Equal(new[] { "part-1", "part-0" }, p.Placements.Select(x => x.PartId))); var nest = new Nest(); @@ -41,7 +42,7 @@ public class CutoutPipelinePersistenceTests { var committed = NestPipelineCommit.ApplyToEmptyPlates(result, manager); Assert.Equal(2, committed.Sum(p => p.Quantity)); - Assert.All(committed, plate => Assert.Equal(new[] { frame, insert }, + Assert.All(committed, plate => Assert.Equal(new[] { insert, frame }, plate.Parts.Select(p => p.BaseDrawing))); } Assert.Equal(2, frame.Quantity.Nested); @@ -59,7 +60,7 @@ public class CutoutPipelinePersistenceTests Assert.Equal(expected.PartSpacing, actual.PartSpacing); Assert.Equal(expected.Quadrant, actual.Quadrant); Assert.Equal(expected.EdgeSpacing, actual.EdgeSpacing); - Assert.Equal(new[] { "neutral-frame", "neutral-insert" }, + Assert.Equal(new[] { "neutral-insert", "neutral-frame" }, actual.Parts.Select(p => p.BaseDrawing.Name)); for (var j = 0; j < actual.Parts.Count; j++) { diff --git a/docs/automatic-nesting.md b/docs/automatic-nesting.md index ab185ff5..e2c2af3b 100644 --- a/docs/automatic-nesting.md +++ b/docs/automatic-nesting.md @@ -4,9 +4,10 @@ 1. Snapshot caller requirements and stock into a `NestJob`. 2. Resolve the selected name through `NestingEngineRegistry` (built-ins and plug-ins use the same path). -3. Run the engine without mutating caller drawings or plates. -4. Check returned placements with the independent `NestLayoutCheck` used by the benchmark. -5. Consolidate exact ordered nonempty layouts only when the entire result is valid, then bind each layout to the caller's drawing references; the caller decides whether to commit it. +3. Bundle parts that fit other parts' enclosed cutouts into their frames ([filling cutouts](nesting-engines.md#filling-cutouts)); a refused or less complete bundled trial is replaced by the ordinary solve. +4. Run the engine without mutating caller drawings or plates. +5. Check returned placements with the independent `NestLayoutCheck` used by the benchmark (for bundles, again after expanding them to physical frames and inserts). +6. Consolidate exact ordered nonempty layouts only when the entire result is valid, then bind each layout to the caller's drawing references; the caller decides whether to commit it. `NestStockBuilder.FromTemplate` snapshots sheet options and spacing settings for multiple-sheet jobs. `SinglePlate` offers exactly one physical sheet, regardless of the legacy plate repeat quantity. `NestPipelineCommit.ApplyToEmptyPlates` applies accepted desktop proposals to empty/new plates with the checked size, quadrant, spacing and total-copy quantity semantics. Quantity is set before parts are attached, so drawing counts multiply exactly once. It never fills occupied plates. diff --git a/docs/cutting-planner.md b/docs/cutting-planner.md index 91194726..9d06bb1d 100644 --- a/docs/cutting-planner.md +++ b/docs/cutting-planner.md @@ -109,6 +109,9 @@ the final replay enforce them: pairs; containment is proven on native clean material. Touching or crossing boundaries, or material that cannot be captured, refuse as `UnsupportedGeometry` naming both parts. A part in a concave pocket outside the host's material has no dependency. + Auto Nest's cutout pre-pass commits each insert before its frame, so an unedited + cutouts-first plate already satisfies this order; its leads are still checked + ([nesting engines](nesting-engines.md#filling-cutouts)). - A preserved manual order that violates a prerequisite, or a cycle, is a `ConstraintConflict`. Replay rechecks the captured prerequisites and refuses a violating order rather than trusting the search. diff --git a/docs/nesting-engines.md b/docs/nesting-engines.md index 1e508f57..ffffd4a1 100644 --- a/docs/nesting-engines.md +++ b/docs/nesting-engines.md @@ -133,12 +133,12 @@ Default inherits these fast paths by running the two engines; other engines and keep their existing search behavior. Quantity-bearing presentation is a separate validated-pipeline representation; repeating does not bypass whole-job validation or change cutting programs. -## Filling cutouts (not yet in production) +## Filling cutouts -Placing parts inside another part's enclosed cutout is being built as a step that runs before any -engine, so every engine benefits. Only an internal test-only pipeline preview calls it: a part -inside a cutout must be cut before the cutout's contour. Ordinary whole-job calls do not enable -this path. +Every whole-job `NestPipeline.Run` (desktop Auto Nest, Console, MCP and API) first places parts +inside other parts' enclosed cutouts, before the selected engine, so every engine benefits and +none needs to know about holes. The steps below prepare frame-plus-insert bundles; the engine +places each bundle's frame as an ordinary part and never sees its reserved inserts. `CutoutLatticeFill` (`OpenNest.Engine/Jobs/Cutouts/`) fills one closed cutout with copies of one part. It runs Fill over the cutout's bounds plus one part step on every side, then shifts @@ -163,7 +163,7 @@ NFP first, then Fill if demand remains, taking the higher-count valid proposal. An 0.20 NFP-only rule would lose a second 4-inch square in a 10-inch round hole. Six geometry-only, anonymized real-job probes and neutral ring fixtures informed the search-order hints; these limited cases are not a global density guarantee. -The internal `NestPipeline.RunCutoutPreview` builds quantity-one proxy instances for eligible +`CutoutPipelinePrepass` builds quantity-one proxy instances for eligible closed-cutout frames. It considers legal frame rotations without outer-perimeter symmetry reduction, shifts insert rotation policies into the local frame, and reserves certified router poses by original requirement ID. It can fill multiple holes and repeated/different frame @@ -174,21 +174,35 @@ offered to the selected engine once. Only actually placed proxies expand to orig insert identities. An unplaced proxy's reservations return to the original unplaced demand; they cannot be packed retroactively in that same solve. Engine indices and metadata are checked, then every physical pose, stock, rotation, quantity and fulfillment is independently validated -before binding caller drawings. Incomplete, forged or geometrically invalid composites return -no bindable plates even with invalid-result consent. Candidate evaluation progress is forwarded +before binding caller drawings. Forged or geometrically invalid composites return no bindable +plates even with invalid-result consent. Candidate evaluation progress is forwarded with unverified proxy commitment counts zeroed; transformed proxy commits are hidden. Physical commits are reported only after physical validation. Preparation is bounded to 32 bundles and 16 base sweep-angle samples per frame (automatic quarter-turns are all retained); remaining copies stay ordinary requirements. Fixed legal proxy orientations are a conservative search choice, not full engine choice among all legal orientations; unsupported -bundles remain ordinary independent requirements. This is an internal pipeline path, not a -production setting or cut-ready nest. +bundles remain ordinary independent requirements. -Committed preview parts keep the frame before its inserts, so they are not cut-ready as placed. -Pre-post verification warns on a nested part cut after its host and on host/insert leads that -meet the other part (see [pre-post verification](post-verification.md)); Plan Cutting on those -parts cuts each insert before its frame and refuses a kept order that does not. Enabling the -pre-pass for ordinary whole-job calls is still pending (Windows acceptance and front-end wiring). +`NestPipeline.Run` keeps a bundled trial that is valid and complete. Otherwise it also runs the +ordinary solve of the same job (with last-resort clamp removal, which the bundled trial does not +offer) and returns: + +- the ordinary solve when the trial was refused, with the refusal on + `NestPipelineResult.RefusedCutoutTrial` (never in `Violations`, since the refused trial has no + bindable plates); +- the ordinary solve when the trial was incomplete and the valid ordinary solve places more + physical parts; +- otherwise the incomplete trial, which is a keepable layout like any other incomplete result. + +So a job with cutouts may take up to two engine solves; a job without eligible frames takes +one, exactly as before. Expanded sheets list each frame's inserts immediately before the frame, +so the committed part order cuts every insert before the cutout that would release its slug. +Leads are not changed: pre-post verification still warns when a host's hole lead or an insert's +lead meets the other part, or when a later reorder (by hand or Auto Sequence) puts the host +first (see [pre-post verification](post-verification.md)); Plan Cutting cuts each insert before +its frame and refuses a kept order that does not. Engine benchmarks call engines directly and do +not run this pre-pass; internal `NestPipeline.RunWithoutCutouts` is the ordinary-solve baseline +for tests. ## Renamed engines diff --git a/docs/post-verification.md b/docs/post-verification.md index ba3db64a..bf7756a2 100644 --- a/docs/post-verification.md +++ b/docs/post-verification.md @@ -102,8 +102,10 @@ shows the first three check categories, even when there are no findings: material. Leads already reported as fixed leads are not reported again. A pair whose containment cannot be established (touching or crossing boundaries, uncapturable material) is incomplete, never clear. Each line appears in the summary only when there is such a - finding. Plan Cutting's Ready result for the plate cuts nested parts first with leads that - clear them; this review checks the live programs again before posting. + finding. Auto Nest commits cutout inserts before their frame, so an unedited plate has no + order finding; leads keep their own checks. Plan Cutting's Ready result for the plate cuts + nested parts first with leads that clear them; this review checks the live programs again + before posting. Findings identify the plate and part's position in the cutting sequence. Read the scrollable report, cancel to fix the nest, then post again to rerun verification.