mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-11-15 03:15:34 -05:00
NestPipelineResult.Unplaced lists each requirement's unplaced copies with a typed NestUnplacedReason, counted from the returned placements: no stock fits at the sampled rotations, reserved in a cutout of an unplaced frame, stock exhausted, plate limit, no position found, or proposal not keepable. Desktop Auto Nest, Console --autonest and MCP autonest_plate/autonest_job report them; the API returns NestResponse.Unplaced, persisted from response schema 5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
180 lines
8.3 KiB
C#
180 lines
8.3 KiB
C#
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)));
|
|
// The second frame copy found no position; its reserved insert is named as such.
|
|
Assert.Equal(new[] {
|
|
("part-0", 1, NestUnplacedReason.NoPlacementFound),
|
|
("part-1", 1, NestUnplacedReason.FrameUnplaced) },
|
|
result.Unplaced.Select(u => (u.PartId, u.Count, u.Reason)));
|
|
Assert.Same(request.Items[1].Drawing, result.Unplaced[1].Drawing);
|
|
|
|
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)));
|
|
}
|
|
|
|
[Fact]
|
|
public void UnplacedPartsNameTheirRequirementAndReason()
|
|
{
|
|
var oversized = new NestItem { Drawing = new Drawing("oversized", TestShapes.Rectangle(30, 30)), Quantity = 2 };
|
|
var request = Request("stub") with
|
|
{
|
|
Items = new[] { Request("stub").Items[0], oversized },
|
|
};
|
|
request.Items[0].Quantity = 3;
|
|
var result = NestPipeline.Run(new Stub(job => Place(job, ("part-0", 11, 11))), "stub", request);
|
|
Assert.True(result.IsValid, string.Join("; ", result.Violations));
|
|
Assert.Equal(new[] {
|
|
("part-0", 2, NestUnplacedReason.NoPlacementFound),
|
|
("part-1", 2, NestUnplacedReason.NoStockFits) },
|
|
result.Unplaced.Select(u => (u.PartId, u.Count, u.Reason)));
|
|
|
|
var complete = NestPipeline.Run(Request("Irregular"));
|
|
Assert.Equal(NestJobStatus.Complete, complete.Status);
|
|
Assert.Empty(complete.Unplaced);
|
|
}
|
|
|
|
[Fact]
|
|
public void UnkeepableProposalListsAllDemandAsNotKeepable()
|
|
{
|
|
var request = Request("stub");
|
|
var result = NestPipeline.Run(new Stub(job => new NestJobResult(
|
|
NestJobStatus.Complete, NestJobStopReason.Completed,
|
|
new[] { new NestJobPlateResult(0, job.Plates[0], new[] {
|
|
new NestJobPlacement("ghost", 0, 11, 11, 0) }) },
|
|
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>())), "stub", request);
|
|
Assert.False(result.CanKeep);
|
|
Assert.NotEmpty(result.RefusedCutoutTrial);
|
|
Assert.Equal(new[] {
|
|
("part-0", 1, NestUnplacedReason.ProposalNotKeepable),
|
|
("part-1", 1, NestUnplacedReason.ProposalNotKeepable) },
|
|
result.Unplaced.Select(u => (u.PartId, u.Count, u.Reason)));
|
|
}
|
|
}
|