mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-27 03:51:05 -04:00
feat(engine): fill cutouts first in every whole-job run
NestPipeline.Run now bundles parts that fit other parts' enclosed cutouts into their frames before the selected engine, for desktop, Console, MCP and API alike. A valid complete bundled trial is kept; a refused trial falls back to the ordinary solve (reason on RefusedCutoutTrial), and an incomplete trial is compared with it, keeping whichever valid proposal places more. An incomplete but valid trial is now keepable like any incomplete result. Expanded sheets list each frame's inserts before the frame, so the committed order cuts every insert before the cutout that releases its slug. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6ac56c4a3f
commit
4a1587d6bd
10 files changed
+363
-137
No files matched your search
@@ -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<OperationCanceledException>(() =>
|
||||
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<OperationCanceledException>(() => NestPipeline.RunCutoutPreview(
|
||||
Assert.Throws<OperationCanceledException>(() => 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<NestJobPlateResult>(),
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using OpenNest.Engine.Jobs;
|
||||
using OpenNest.Geometry;
|
||||
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
|
||||
|
||||
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
[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<NestJob, NestJobResult> solve) : INestingEngine
|
||||
{
|
||||
public List<NestJob> Jobs { get; } = new();
|
||||
|
||||
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? 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<OperationCanceledException>(() =>
|
||||
NestPipeline.Run(engine, "stub", Request("stub"), token: cts.Token));
|
||||
Assert.True(IsTrial(Assert.Single(engine.Jobs)));
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,9 @@ using OpenNest.Engine.Jobs.Adapters;
|
||||
|
||||
namespace OpenNest.Engine.Jobs.Cutouts;
|
||||
|
||||
/// <summary>Internal-only frame bundles. No normal pipeline caller enables this.</summary>
|
||||
/// <summary>Frame bundles prepared by <see cref="NestPipeline"/> 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.</summary>
|
||||
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
|
||||
}
|
||||
|
||||
/// <summary>Reject untrusted accounting before expansion. A failed trial is never bindable,
|
||||
/// including with allowInvalid. Only actually placed proxies consume reserved inserts.</summary>
|
||||
/// including with allowInvalid. Only actually placed proxies consume reserved inserts;
|
||||
/// an unplaced proxy's inserts stay unplaced demand of their own requirements.</summary>
|
||||
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);
|
||||
|
||||
@@ -75,6 +75,16 @@ public sealed class NestPipelineResult
|
||||
public TimeSpan SolveTime { get; }
|
||||
public TimeSpan ValidationTime { get; }
|
||||
internal IReadOnlyDictionary<string, Drawing> DrawingsByPartId { get; }
|
||||
|
||||
/// <summary>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 <see cref="Violations"/>:
|
||||
/// the refused trial has no bindable plates.</summary>
|
||||
public IReadOnlyList<string> RefusedCutoutTrial { get; private init; } = Array.Empty<string>();
|
||||
|
||||
internal NestPipelineResult WithRefusedCutoutTrial(IReadOnlyList<string> refusals) =>
|
||||
new(EngineName, Job, Raw, Plates, Violations, CanKeep, SolveTime, ValidationTime,
|
||||
DrawingsByPartId)
|
||||
{ RefusedCutoutTrial = refusals };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -108,39 +118,85 @@ public static class NestPipeline
|
||||
return Run(engine, request.EngineName, request, progress, token);
|
||||
}
|
||||
|
||||
/// <summary>Runs a caller-supplied engine instance through the same validation and binding.</summary>
|
||||
/// <summary>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 <see cref="CutoutPipelinePrepass"/>. 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.</summary>
|
||||
public static NestPipelineResult Run(
|
||||
INestingEngine engine,
|
||||
string engineName,
|
||||
NestPipelineRequest request,
|
||||
IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default
|
||||
) => RunCore(engine, engineName, request, progress, token, cutoutPreview: false);
|
||||
) => Run(engine, engineName, request, progress, token, cutouts: true);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
internal static NestPipelineResult RunCutoutPreview(
|
||||
/// <summary>The ordinary solve alone, with no cutout pre-pass: a characterization baseline
|
||||
/// for tests and benchmarks, not a front-end setting.</summary>
|
||||
internal static NestPipelineResult RunWithoutCutouts(
|
||||
NestPipelineRequest request,
|
||||
IProgress<NestJobProgress> progress = null,
|
||||
CancellationToken token = default
|
||||
) => Run(NestingEngineRegistry.Create(request.EngineName), request.EngineName, request,
|
||||
progress, token, cutouts: false);
|
||||
|
||||
/// <summary>The bundled cutout trial alone, without the ordinary-solve comparison, so tests
|
||||
/// can exercise its validation boundary. Jobs without bundles run the ordinary solve.</summary>
|
||||
internal static NestPipelineResult RunCutoutTrial(
|
||||
INestingEngine engine,
|
||||
string engineName,
|
||||
NestPipelineRequest request,
|
||||
IProgress<NestJobProgress> 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<NestJobProgress> 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<NestJobProgress> 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<string, Drawing> 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<NestJobProgress> 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<string, Drawing> drawingsByPartId,
|
||||
IProgress<NestJobProgress> 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,
|
||||
|
||||
@@ -10,14 +10,14 @@ using OpenNest.Tests.CuttingPlanning;
|
||||
namespace OpenNest.Tests.Engine;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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));
|
||||
|
||||
@@ -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++)
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+29
-15
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user