This commit is contained in:
aj committed 2026-10-10 12:38:50 -04:00
commit ef6ca80e6d
38 files changed
+2896 -186

No files matched your search

@@ -0,0 +1,154 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Engine.Tests.NestingEngines;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
public class CutoutNfpProposalTests
{
private const double Spacing = 0.25;
[Fact]
public void LargeInsertFitsRoundCutoutAndRemainsValidAfterFrameRotation()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1, RotationPolicy.Fixed(System.Math.PI / 2));
var insert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(0));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.Equal(insert.Id, pose.PartId);
Assert.Equal(0, pose.InstanceIndex);
Assert.True(insert.Rotation.Allows(pose.Rotation));
AssertSquareInRoundHole(pose, 6, Spacing);
var localFrame = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), localFrame,
JobPartGeometry.Read(insert.Geometry), pose, Spacing));
var framePose = new NestJobPlacement(frame.Id, 0, 11, 11, System.Math.PI / 2);
var movedInsert = pose with
{
X = framePose.X - pose.Y,
Y = framePose.Y + pose.X,
Rotation = pose.Rotation + framePose.Rotation,
};
// This insert is fixed at zero for the local proposal; the rotated variant needs
// a correspondingly legal global policy before it can be handed to a job.
var rotatedInsert = JobBuilder.Rectangle("insert", 6, 6, 1, RotationPolicy.Fixed(System.Math.PI / 2));
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry), framePose,
JobPartGeometry.Read(insert.Geometry), movedInsert, Spacing));
AssertSquareInRoundHole(movedInsert, 6, Spacing, framePose.X, framePose.Y);
var stock = JobBuilder.Stock("sheet", 22, 22, Spacing);
var job = JobBuilder.Job(new[] { frame, rotatedInsert }, new[] { stock });
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, new[] { framePose, movedInsert }) },
new[] { new PartFulfillment(frame.Id, 1, 1, 0), new PartFulfillment(insert.Id, 1, 1, 0) },
new[] { new StockUsage(stock.Id, 1, null) });
LayoutAssert.Valid(job, result);
}
[Fact]
public void LargeInsertFitsSquareCutoutWithoutUsingFillLattice()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
var frame = JobBuilder.Part("square-frame", program, 1);
var insert = JobBuilder.Rectangle("large", 9, 9, 1, RotationPolicy.Fixed(0));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.All(new[] { (0.0, 0.0), (9.0, 0.0), (9.0, 9.0), (0.0, 9.0) }, corner =>
{
Assert.InRange(pose.X + corner.Item1, 5 + Spacing, 15 - Spacing);
Assert.InRange(pose.Y + corner.Item2, 5 + Spacing, 15 - Spacing);
});
Assert.True(NestLayoutCheck.Clears(JobPartGeometry.Read(frame.Geometry),
new NestJobPlacement(frame.Id, 0, 0, 0, 0), JobPartGeometry.Read(insert.Geometry), pose, Spacing));
Assert.Null(CutoutNfpProposal.Find(frame, 0, JobBuilder.Rectangle("oversized", 9.6, 9.6, 1),
Spacing));
}
[Fact]
public void ObliqueFixedInsertRotationIsUsedInInnerFit()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
var frame = JobBuilder.Part("frame", program, 1);
var insert = JobBuilder.Rectangle("insert", 3, 2, 1, RotationPolicy.Fixed(System.Math.PI / 4));
var pose = CutoutNfpProposal.Find(frame, 0, insert, Spacing);
Assert.NotNull(pose);
Assert.True(insert.Rotation.Allows(pose.Rotation));
foreach (var (x, y) in new[] { (0.0, 0.0), (3.0, 0.0), (3.0, 2.0), (0.0, 2.0) })
{
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
Assert.InRange(rx, 5 + Spacing, 15 - Spacing);
Assert.InRange(ry, 5 + Spacing, 15 - Spacing);
}
}
[Theory]
[InlineData(9.51)]
[InlineData(10.0)]
[InlineData(9.6)]
public void OversizedInsertIsNeverProposed(double side)
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", side, side, 1);
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, Spacing));
}
[Fact]
public void UnsupportedCoordinateScaleFailsClosed()
{
const double scale = 1e15;
var program = TestShapes.Polyline((-scale, -scale), (scale, -scale),
(scale, scale), (-scale, scale));
program.MoveTo(-scale / 2, -scale / 2);
program.LineTo(-scale / 2, scale / 2);
program.LineTo(scale / 2, scale / 2);
program.LineTo(scale / 2, -scale / 2);
program.LineTo(-scale / 2, -scale / 2);
Assert.Null(CutoutNfpProposal.Find(JobBuilder.Part("huge", program, 1), 0,
JobBuilder.Rectangle("insert", 6, 6, 1), Spacing));
}
[Fact]
public void ImpossibleHugeSpacingFailsBeforeAnyOffset()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
Assert.Null(CutoutNfpProposal.Find(frame, 0, insert, 1e12));
}
private static void AssertSquareInRoundHole(NestJobPlacement pose, double side, double spacing,
double centerX = 0, double centerY = 0)
{
var sin = System.Math.Sin(pose.Rotation);
var cos = System.Math.Cos(pose.Rotation);
foreach (var (x, y) in new[] { (0.0, 0.0), (side, 0.0), (side, side), (0.0, side) })
{
var dx = pose.X + x * cos - y * sin - centerX;
var dy = pose.Y + x * sin + y * cos - centerY;
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) <= 5 - spacing + 0.00001,
$"corner ({dx}, {dy}) does not clear the circular wall");
}
}
[Fact]
public void CancellationIsNotReportedAsNoFit()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Rectangle("insert", 6, 6, 1);
using var cancelled = new CancellationTokenSource();
cancelled.Cancel();
Assert.Throws<OperationCanceledException>(() => CutoutNfpProposal.Find(frame, 0, insert, Spacing,
cancelled.Token));
}
}
@@ -0,0 +1,749 @@
using OpenNest.CNC;
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Geometry;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
[CollectionDefinition("Cutout pipeline registry", DisableParallelization = true)]
public sealed class CutoutPipelineRegistryCollection { }
[Collection("Cutout pipeline registry")]
public class CutoutPipelinePreviewTests
{
private static NestPipelineRequest Request(string engine = "Irregular")
{
var frame = new Drawing("frame", TestShapes.Ring(20, 10));
var insert = new Drawing("insert", TestShapes.Rectangle(3, 3));
return new NestPipelineRequest(engine,
new[] { new NestItem { Drawing = frame, Quantity = 1 },
new NestItem { Drawing = insert, Quantity = 1 } },
new[] { new NestPlateStock("sheet", new Size(22, 22), 1, 0.25) });
}
[Theory]
[InlineData("Irregular")]
[InlineData("Default")]
public void RegisteredEngineExpandsOnlyPhysicalOriginalDrawings(string engine)
{
var request = Request(engine);
var result = NestPipeline.RunCutoutPreview(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));
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);
}
private sealed class Stub(Func<NestJob, NestJobResult> solve) : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default) => solve(job);
}
private static NestJobResult Result(NestJob job, params NestJobPlacement[] poses)
{
var placed = poses.GroupBy(p => p.PartId).ToDictionary(g => g.Key, g => g.Count());
var complete = job.Parts.All(p => placed.GetValueOrDefault(p.Id) == p.Quantity);
return new NestJobResult(complete ? NestJobStatus.Complete : NestJobStatus.Incomplete,
complete ? NestJobStopReason.Completed : NestJobStopReason.NoPlacementFound,
poses.Length == 0 ? Array.Empty<NestJobPlateResult>() :
new[] { new NestJobPlateResult(0, job.Plates[0], poses) },
job.Parts.Select(p => new PartFulfillment(p.Id, p.Quantity,
placed.GetValueOrDefault(p.Id), p.Quantity - placed.GetValueOrDefault(p.Id))),
new[] { new StockUsage(job.Plates[0].Id, poses.Length == 0 ? 0 : 1,
job.Plates[0].Quantity - (poses.Length == 0 ? 0 : 1)) });
}
[Fact]
public void SingleSolveReceivesOneProxyAndNoDuplicatedInsertDemand()
{
var called = 0;
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
called++;
var proxy = Assert.Single(job.Parts);
Assert.StartsWith("__cutout-proxy-", proxy.Id);
Assert.True(proxy.Rotation.Allows(0));
Assert.False(proxy.Rotation.Allows(System.Math.PI / 2));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}), "stub", request);
Assert.Equal(1, called);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(2, result.Plates.Single().Parts.Count);
}
[Fact]
public void IncompleteEngineDoesNotBindReservedInsertEvenWithInvalidConsent()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job => Result(job)), "stub", request);
Assert.False(result.CanKeep);
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 id = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-")).Id;
var pose = new NestJobPlacement(id, 1, 11, 11, 0);
return Result(job, pose);
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("accounting"));
}
[Fact]
public void PartialBundleKeepsIndependentResidualByOriginalRequirement()
{
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 proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
var residual = job.Parts.Single(p => p.Id == "part-1");
Assert.Equal(1, residual.Quantity);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
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" },
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));
Assert.All(result.Plates.Single().Parts.Where(p => p.BaseDrawing == request.Items[1].Drawing),
p => Assert.Same(request.Items[1].Drawing, p.BaseDrawing));
}
[Fact]
public void UntrustedStockOverdrawOrRotationCannotBindAComposite()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
var proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11,
System.Math.PI / 2));
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("rotation constraint"));
var overdraw = NestPipeline.RunCutoutPreview(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) }),
new NestJobPlateResult(1, job.Plates[0], new[] {
new NestJobPlacement(job.Parts[0].Id, 1, 11, 11, 0) }) },
Array.Empty<PartFulfillment>(), Array.Empty<StockUsage>())), "stub", request);
Assert.False(overdraw.CanKeep);
Assert.Empty(overdraw.Plates);
Assert.Contains(overdraw.Violations, v => v.Contains("only 1 are available"));
}
[Fact]
public void CancellationBeforePreparationDoesNotCallEngine()
{
using var cts = new CancellationTokenSource();
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));
}
[Fact]
public void EnvelopeValidResidualInsideReservedHoleFailsAfterPhysicalExpansion()
{
var request = Request("stub");
request.Items[1].Quantity = 2;
var result = NestPipeline.RunCutoutPreview(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),
new NestJobPlacement("part-1", 0, 11, 11, 0));
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("required spacing"));
AssertCannotCommit(result, request);
}
private static void AssertCannotCommit(NestPipelineResult result, NestPipelineRequest request)
{
var nest = new Nest();
foreach (var item in request.Items)
nest.Drawings.Add(item.Drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
Assert.Throws<InvalidOperationException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true));
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested));
}
private sealed class CollectProgress : IProgress<NestJobProgress>
{
public List<NestJobProgress> Events { get; } = new();
public void Report(NestJobProgress value) => Events.Add(value);
}
[Fact]
public void EvaluationProgressSurvivesButUnverifiedCommitsNeverEscape()
{
var request = Request("stub");
var progress = new CollectProgress();
var result = NestPipeline.RunCutoutPreview(new ProgressStub(), "stub", request, progress);
Assert.False(result.CanKeep);
Assert.Single(progress.Events);
Assert.Equal(NestJobStage.EvaluatingCandidate, progress.Events[0].Stage);
Assert.Equal(0, progress.Events[0].CommittedPlates);
Assert.Equal(0, progress.Events[0].CommittedParts);
}
private sealed class ProgressStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate,
job.Plates[0].Id, 0, 1, 1));
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
job.Plates[0].Id, 0, 1, 1));
return Result(job);
}
}
[Fact]
public void ReorderedButContiguousEngineIndicesAreAccepted()
{
var request = Request("stub");
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 proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
Assert.Equal(2, job.Parts.Single(p => p.Id == "part-1").Quantity);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0),
new NestJobPlacement("part-1", 1, 22, 1, 0),
new NestJobPlacement("part-1", 0, 22, 9, 0));
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { 0, 1, 2 }, result.Raw.Plates.Single().Placements
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ForgedFulfillmentOrStockMetadataCannotBind(bool fulfillment)
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(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));
return new NestJobResult(good.Status, good.StopReason, good.Plates,
fulfillment ? new[] { new PartFulfillment(proxy.Id, 1, 0, 1) } : good.Fulfillment,
fulfillment ? good.StockUsage : new[] { new StockUsage("sheet", 0, 1) });
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("accounting"));
}
[Fact]
public void SameDrawingInTwoRequirementRowsRetainsSeparateIds()
{
var initial = Request("stub");
var request = initial with
{
Items = new[] { initial.Items[0], initial.Items[1],
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 proxy = job.Parts.Single(p => p.Id.StartsWith("__cutout-proxy-"));
var residual = job.Parts.Where(p => p.Id != proxy.Id)
.Select((p, i) => new NestJobPlacement(p.Id, 0, 22, 1 + 5 * i, 0));
return Result(job, new[] { new NestJobPlacement(proxy.Id, 0, 11, 11, 0) }
.Concat(residual).ToArray());
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-1", "part-2" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
Assert.Same(result.Plates.Single().Parts[1].BaseDrawing,
result.Plates.Single().Parts[2].BaseDrawing);
}
[Fact]
public void RepeatedFrameReservationsExpandOnTwoPhysicalSheets()
{
var request = Request("stub");
request.Items[0].Quantity = 2;
request.Items[1].Quantity = 2;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
request = request with
{
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
Options = new NestJobOptions(maxPlates: 2)
};
var calls = 0;
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
calls++;
var proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
Assert.Equal(2, proxies.Length);
Assert.Equal(2, job.Parts.Count);
var builder = new NestJobResultBuilder(job);
foreach (var proxy in proxies)
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
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" },
plate.Placements.Select(p => p.PartId)));
Assert.All(result.Raw.Fulfillment, f => Assert.Equal((2, 2, 0),
(f.Requested, f.Placed, f.Unplaced)));
Assert.Equal(new[] { 0, 1 }, result.Raw.Plates.SelectMany(p => p.Placements)
.Where(p => p.PartId == "part-1").Select(p => p.InstanceIndex));
}
[Fact]
public void UnplacedFrameReleasesInsertDemandWithoutABindablePartialProposal()
{
var request = Request("stub");
request.Items[0].Quantity = 2;
request.Items[1].Quantity = 2;
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 =>
{
calls++;
var first = job.Parts.Single(p => p.Id.EndsWith("-0"));
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[0], new[] { (first.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.NoPlacementFound);
}), "stub", request);
Assert.Equal(1, calls);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
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);
}
[Fact]
public void DifferentFrameRequirementsShareScarceInsertsByIdentity()
{
var initial = Request("stub");
var frame2 = new Drawing("second-frame", TestShapes.Ring(18, 10));
initial.Items[1].Quantity = 2;
initial.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
var request = initial with
{
Items = new[] { initial.Items[0],
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 proxies = job.Parts.Where(p => p.Id.StartsWith("__cutout-proxy-")).ToArray();
Assert.Equal(2, proxies.Length);
Assert.Equal(2, job.Parts.Count);
var builder = new NestJobResultBuilder(job);
foreach (var proxy in proxies)
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
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" },
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));
}
[Fact]
public void FixedQuarterTurnComposesLocalInsertRotationAndTranslation()
{
var frame = TestShapes.Rectangle(20, 20);
frame.MoveTo(5, 5);
frame.LineTo(5, 15);
frame.LineTo(15, 15);
frame.LineTo(15, 5);
frame.LineTo(5, 5);
var quarter = System.Math.PI / 2;
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 1,
StepAngle = quarter, RotationStart = quarter, RotationEnd = quarter },
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 proxy = Assert.Single(job.Parts);
Assert.True(proxy.Rotation.Allows(quarter));
Assert.False(proxy.Rotation.Allows(0));
return Result(job, new NestJobPlacement(proxy.Id, 0, 21, 1, quarter));
}), "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);
// 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);
foreach (var dx in new[] { 0.0, 3.0 })
foreach (var dy in new[] { 0.0, 3.0 })
{
Assert.InRange(localX + dx, 5.25, 14.75);
Assert.InRange(localY + dy, 5.25, 14.75);
}
}
[Fact]
public void HeterogeneousStockUsesConservativeSpacingAndOriginalQuadrant()
{
var request = Request("stub") with
{
Stock = new[] {
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 proxy = Assert.Single(job.Parts);
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[1], new[] { (proxy.Id, -11.0, -11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal("wide", result.Raw.Plates.Single().StockId);
Assert.Equal(3, result.Plates.Single().Stock.Quadrant);
Assert.Equal(0.6, result.Plates.Single().Stock.PartSpacing);
Assert.Equal(2, result.Plates.Single().Parts.Count);
}
[Fact]
public void RegisteredPluginRunsTheSamePhysicalExpansionBoundary()
{
var name = "CutoutPlugin-" + Guid.NewGuid().ToString("N");
var calls = 0;
NestingEngineRegistry.Register(name, "test plugin", () => new Stub(job =>
{
calls++;
var proxy = Assert.Single(job.Parts);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}));
try
{
var result = NestPipeline.RunCutoutPreview(Request(name));
Assert.Equal(1, calls);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
Assert.Equal(new[] { "part-0", "part-1" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
}
finally
{
var registry = Assert.IsType<List<NestingEngineInfo>>(NestingEngineRegistry.AvailableEngines);
Assert.Equal(1, registry.RemoveAll(info => info.Name == name));
}
}
[Fact]
public void TwoCutoutsReserveDifferentCopiesWithinOneFrame()
{
var program = TestShapes.Rectangle(20, 20);
foreach (var origin in new[] { 2.0, 12.0 })
{
program.MoveTo(origin, origin);
program.LineTo(origin, origin + 6);
program.LineTo(origin + 6, origin + 6);
program.LineTo(origin + 6, origin);
program.LineTo(origin, origin);
}
var frame = new Drawing("two-hole-frame", program);
var insert = new Drawing("insert", TestShapes.Rectangle(4, 4));
var request = new NestPipelineRequest("stub", new[] {
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 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();
Assert.Equal(2, inserts.Length);
Assert.All(inserts, p =>
{
var localX = p.X - 1;
var localY = p.Y - 1;
Assert.Contains(new[] { 2.0, 12.0 }, origin =>
localX >= origin + 0.25 && localX + 4 <= origin + 5.75
&& localY >= origin + 0.25 && localY + 4 <= origin + 5.75);
});
}
[Fact]
public void DifferentPrioritiesRemainIndependentInsteadOfElevatingAnInsert()
{
var request = Request("stub");
request.Items[1].Priority = 3;
var result = NestPipeline.RunCutoutPreview(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);
}
[Theory]
[InlineData(1, 11.0, 11.0)]
[InlineData(2, -11.0, 11.0)]
[InlineData(3, -11.0, -11.0)]
[InlineData(4, 11.0, -11.0)]
public void AllQuadrantsKeepPhysicalStockAndExpansion(int quadrant, double x, double y)
{
var request = Request("stub") with
{
Stock = new[] {
new NestPlateStock("sheet", new Size(22, 22), 1, 0.25,
new Spacing(0.5, 0.5), quadrant) },
Options = new NestJobOptions(maxPlates: 1)
};
var result = NestPipeline.RunCutoutPreview(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));
}
[Fact]
public void CancellationAfterAnEngineIgnoresStopDiscardsEveryBundle()
{
var request = Request("stub");
using var cts = new CancellationTokenSource();
Assert.Throws<OperationCanceledException>(() => NestPipeline.RunCutoutPreview(
new Stub(job =>
{
var proxy = Assert.Single(job.Parts);
cts.Cancel();
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}), "stub", request, token: cts.Token));
Assert.All(request.Items, item => Assert.Equal(0, item.Drawing.Quantity.Nested));
}
[Fact]
public void CompleteStatusWithMissingProxyIsUntrustedAndNotBindable()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(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)),
job.Plates.Select(s => new StockUsage(s.Id, 0, s.Quantity)))), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("accounting"));
AssertCannotCommit(result, request);
}
[Fact]
public void SuccessfulCompositeReportsOnlyValidatedPhysicalCommitCounts()
{
var request = Request("stub");
var progress = new CollectProgress();
var result = NestPipeline.RunCutoutPreview(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));
Assert.Equal(2, progress.Events.Last().CommittedParts);
Assert.Equal(1, progress.Events.Last().CommittedPlates);
}
private sealed class ReportingStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var proxy = Assert.Single(job.Parts);
progress?.Report(new NestJobProgress(NestJobStage.EvaluatingCandidate,
job.Plates[0].Id, 0, 0, 0));
progress?.Report(new NestJobProgress(NestJobStage.PlateCommitted,
job.Plates[0].Id, 0, 1, 1));
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}
}
[Fact]
public void AutomaticBundleRetainsStockFeasibleQuarterTurn()
{
var program = TestShapes.Rectangle(20, 10);
program.MoveTo(2, 2);
program.LineTo(2, 8);
program.LineTo(18, 8);
program.LineTo(18, 2);
program.LineTo(2, 2);
var request = new NestPipelineRequest("Irregular", new[] {
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);
Assert.True(result.IsValid, string.Join("; ", result.Violations));
var frame = result.Raw.Plates.Single().Placements[0];
Assert.Equal(System.Math.PI / 2, frame.Rotation, 7);
Assert.Equal(new[] { "part-0", "part-1" },
result.Raw.Plates.Single().Placements.Select(p => p.PartId));
}
[Fact]
public void ChangedLiveInsertDuringSolveNeverBindsUncheckedComposite()
{
var request = Request("stub");
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
request.Items[1].Drawing.Program = TestShapes.Rectangle(40, 40);
var proxy = Assert.Single(job.Parts);
return Result(job, new NestJobPlacement(proxy.Id, 0, 11, 11, 0));
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("insert"));
AssertCannotCommit(result, request);
}
[Fact]
public void NondefaultOptionsPassUnchangedAndPlateCapRejectsOtherwiseValidOutput()
{
var request = Request("stub");
request.Items[0].Quantity = 2;
request.Items[1].Quantity = 2;
request.Items[1].Drawing.Program = TestShapes.Rectangle(6, 6);
var options = new NestJobOptions("Strip", maxPlates: 1,
salvageRate: 0.4, minimumSalvageDimension: 2);
request = request with
{
Stock = new[] { new NestPlateStock("sheet", new Size(22, 22), 2, 0.25) },
Options = options
};
var result = NestPipeline.RunCutoutPreview(new Stub(job =>
{
Assert.Same(options, job.Options);
Assert.Equal("Strip", job.Options.PlacementStrategy);
Assert.Equal(0.4, job.Options.SalvageRate);
var builder = new NestJobResultBuilder(job);
foreach (var proxy in job.Parts)
builder.AddSheet(job.Plates[0], new[] { (proxy.Id, 11.0, 11.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}), "stub", request);
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("MaxPlates"));
AssertCannotCommit(result, request);
}
[Fact]
public void ObliqueFramePreservesLegalGlobalInsertRotationAndWallClearance()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
const double angle = 0.3;
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = new Drawing("frame", program), Quantity = 1,
StepAngle = 1, RotationStart = angle, RotationEnd = angle },
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 =>
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;
var cosine = System.Math.Cos(angle);
var sine = System.Math.Sin(angle);
var localX = dx * cosine + dy * sine;
var localY = -dx * sine + dy * cosine;
foreach (var x in new[] { 0.0, 3.0 })
foreach (var y in new[] { 0.0, 3.0 })
{
Assert.InRange(localX + x, 5.25, 14.75);
Assert.InRange(localY + y, 5.25, 14.75);
}
}
[Fact]
public void LargeDemandLeavesExcessFramesAndInsertsIndependentAfterBoundedPreparation()
{
var frame = TestShapes.Rectangle(20, 20);
frame.MoveTo(5, 5);
frame.LineTo(5, 15);
frame.LineTo(15, 15);
frame.LineTo(15, 5);
frame.LineTo(5, 5);
var request = new NestPipelineRequest("stub", new[] {
new NestItem { Drawing = new Drawing("frame", frame), Quantity = 40,
StepAngle = 1, RotationStart = 0, RotationEnd = 0 },
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 =>
{
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.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,228 @@
using OpenNest.Engine.Jobs;
using OpenNest.Engine.Jobs.Cutouts;
using OpenNest.Engine.Tests.NestingEngines;
using TestShapes = OpenNest.Engine.Tests.NestingEngines.Shapes;
namespace OpenNest.Engine.Tests.Jobs.Cutouts;
public class CutoutRouterTests
{
private static NestJobPart Frame()
{
var program = TestShapes.Rectangle(20, 20);
program.MoveTo(5, 5);
program.LineTo(5, 15);
program.LineTo(15, 15);
program.LineTo(15, 5);
program.LineTo(5, 5);
return JobBuilder.Part("frame", program, 1);
}
[Fact]
public void SmallLatticeAndLargeResidualKeepTheirOriginalRequirementIds()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var small = JobBuilder.Part("small", TestShapes.Rectangle(1, 1), 3);
var large = JobBuilder.Rectangle("large", 3, 3, 1, RotationPolicy.Fixed(0));
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25,
(pose, lattice) => origins.Add((pose, lattice)));
Assert.Equal(3, poses.Count(p => p.PartId == small.Id));
Assert.Single(poses, p => p.PartId == large.Id);
Assert.Contains(origins, row => row.Pose.PartId == small.Id && row.Lattice);
Assert.Contains(origins, row => row.Pose.PartId == large.Id && !row.Lattice);
Assert.Equal(poses, origins.Select(row => row.Pose));
Assert.Equal(new[] { 0, 1, 2 }, poses.Where(p => p.PartId == small.Id).Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { small, large }, poses);
}
[Fact]
public void ImpossibleRemainderNeverDisplacesCertifiedCopies()
{
var frame = Frame();
var small = JobBuilder.Rectangle("small", 1, 1, 3, RotationPolicy.Fixed(0));
var impossible = JobBuilder.Rectangle("impossible", 9.6, 9.6, 1, RotationPolicy.Fixed(0));
var together = CutoutRouter.Fill(frame, 0, new[] { small, impossible }, 0.25);
Assert.Equal(3, together.Count);
Assert.All(together, p => Assert.Equal(small.Id, p.PartId));
Assert.Equal(Enumerable.Range(0, 3), together.Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { small, impossible }, together);
}
[Fact]
public void PriorCopiesAndQuantityCannotBeBypassedByResidualSearch()
{
var frame = Frame();
var small = JobBuilder.Rectangle("small", 2, 2, 4, RotationPolicy.Fixed(0));
var large = JobBuilder.Rectangle("large", 7, 7, 1, RotationPolicy.Fixed(0));
var poses = CutoutRouter.Fill(frame, 0, new[] { small, large }, 0.25);
Assert.InRange(poses.Count(p => p.PartId == small.Id), 0, small.Quantity);
Assert.InRange(poses.Count(p => p.PartId == large.Id), 0, large.Quantity);
AssertPhysical(frame, new[] { small, large }, poses);
}
[Fact]
public void ResidualNfpDoesNotOverlapTheFirstLargeInsert()
{
var frame = Frame();
var insert = JobBuilder.Rectangle("large", 6, 6, 2, RotationPolicy.Fixed(0));
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
Assert.Single(poses);
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void NfpAddsOneMoreCopyToPartialShiftedLattice()
{
// Neutral 8-inch round hole, 2x1 insert, quarter-inch spacing: the shifted
// lattice keeps 11; an independently certified NFP pose occupies its leftover.
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(2, 1), 12);
var origins = new List<(NestJobPlacement Pose, bool Lattice)>();
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25,
(pose, lattice) => origins.Add((pose, lattice)));
Assert.Equal(12, poses.Count);
Assert.Equal(11, origins.Count(row => row.Lattice));
Assert.Single(origins, row => !row.Lattice);
Assert.Equal(poses, origins.Select(row => row.Pose));
Assert.Equal(Enumerable.Range(0, 12), poses.Select(p => p.InstanceIndex));
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void MiddleRatioChoosesTwoLatticeCopiesOverOneNfpCopy()
{
// 4x4 / (pi*5^2) = 0.204. A hard NFP-only threshold at 0.20 loses one copy.
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var insert = JobBuilder.Part("insert", TestShapes.Rectangle(4, 4), 3);
Assert.InRange(16 / (System.Math.PI * 25), CutoutRouter.SmallRatio, CutoutRouter.LargeRatio);
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
Assert.Equal(2, poses.Count);
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void LargeRatioKeepsAnNfpPoseWhenFillCannotProduceALattice()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(18, 8), 1);
var insert = JobBuilder.Rectangle("insert", 4.5, 4.5, 3, RotationPolicy.Fixed(0));
Assert.True(20.25 / (System.Math.PI * 16) > CutoutRouter.LargeRatio);
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, insert, 3, 0.25));
var poses = CutoutRouter.Fill(frame, 0, new[] { insert }, 0.25);
Assert.Single(poses);
AssertPhysical(frame, new[] { insert }, poses);
}
[Fact]
public void DuplicateRequirementIdsAreRejectedBeforePlacement()
{
var frame = Frame();
var insert = JobBuilder.Rectangle("same", 1, 1, 3);
Assert.Throws<ArgumentException>(() => CutoutRouter.Fill(frame, 0,
new[] { insert, insert }, 0.25));
}
[Fact]
public void OccupiedAwareLatticeFillsAfterAnEarlierCopyBlocksItsFirstPose()
{
var frame = JobBuilder.Part("frame", TestShapes.Ring(20, 10), 1);
var first = JobBuilder.Part("first", TestShapes.Rectangle(1, 1), 1);
var more = JobBuilder.Part("more", TestShapes.Rectangle(1, 1), 3);
var early = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25);
Assert.Equal(3, early.Count);
var blocked = early[0] with { PartId = first.Id };
var occupied = new[] { (JobPartGeometry.Read(first.Geometry), blocked) };
var later = CutoutLatticeFill.Fill(frame, 0, more, 3, 0.25,
CutoutLatticeFill.DefaultShiftSteps, occupied, default);
Assert.Equal(3, later.Count);
Assert.All(later, p => Assert.True(NestLayoutCheck.Clears(occupied[0].Item1, blocked,
JobPartGeometry.Read(more.Geometry), p, 0.25)));
}
[Fact]
public void TinyInsertDoesNotAllocateAnUnboundedLattice()
{
var frame = Frame();
var tiny = JobBuilder.Rectangle("tiny", 0.001, 0.001, 3, RotationPolicy.Fixed(0));
Assert.Empty(CutoutLatticeFill.Fill(frame, 0, tiny, 3, 0));
var poses = CutoutRouter.Fill(frame, 0, new[] { tiny }, 0);
Assert.Equal(3, poses.Count);
}
[Fact]
public void CancellationIsNotNoFit()
{
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.Throws<OperationCanceledException>(() => CutoutRouter.Fill(Frame(), 0,
new[] { JobBuilder.Rectangle("small", 1, 1, 3) }, 0.25, cts.Token));
}
private static void AssertPhysical(NestJobPart frame, NestJobPart[] inserts,
IReadOnlyList<NestJobPlacement> poses)
{
var stock = JobBuilder.Stock("sheet", 22, 22, 0.25);
var job = JobBuilder.Job(new[] { frame }.Concat(inserts).ToArray(), new[] { stock });
var frameBounds = JobPartGeometry.Read(frame.Geometry).Bounds;
var offsetX = 1 - frameBounds.Left;
var offsetY = 1 - frameBounds.Bottom;
var placements = new[] { new NestJobPlacement(frame.Id, 0, offsetX, offsetY, 0) }
.Concat(poses.Select(p => p with { X = p.X + offsetX, Y = p.Y + offsetY })).ToArray();
var result = new NestJobResult(NestJobStatus.Complete, NestJobStopReason.Completed,
new[] { new NestJobPlateResult(0, stock, placements) },
new[] { new PartFulfillment(frame.Id, 1, 1, 0) }
.Concat(inserts.Select(part => new PartFulfillment(part.Id, part.Quantity,
poses.Count(p => p.PartId == part.Id), part.Quantity - poses.Count(p => p.PartId == part.Id))))
.ToArray(),
new[] { new StockUsage(stock.Id, 1, null) });
LayoutAssert.Valid(job, result);
var frameGeometry = JobPartGeometry.Read(frame.Geometry);
var framePose = new NestJobPlacement(frame.Id, 0, 0, 0, 0);
foreach (var pose in poses)
{
var geometry = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry);
Assert.True(NestLayoutCheck.Clears(frameGeometry, framePose, geometry, pose, 0.25));
// Independent square-wall oracle: every corner of each rectangular insert clears the hole.
var bounds = geometry.Bounds;
foreach (var (x, y) in new[] { (bounds.Left, bounds.Bottom), (bounds.Right, bounds.Bottom),
(bounds.Right, bounds.Top), (bounds.Left, bounds.Top) })
{
var rx = pose.X + x * System.Math.Cos(pose.Rotation) - y * System.Math.Sin(pose.Rotation);
var ry = pose.Y + x * System.Math.Sin(pose.Rotation) + y * System.Math.Cos(pose.Rotation);
if (frameBounds.Left >= 0)
{
Assert.InRange(rx, 5.25, 14.75);
Assert.InRange(ry, 5.25, 14.75);
}
else
{
var radius = System.Math.Abs(frameGeometry.Cutouts[0].BoundingBox.Right);
Assert.True(System.Math.Sqrt(rx * rx + ry * ry) <= radius - 0.25 + 0.00001);
}
}
}
// Independent rectangle-vs-rectangle distance, not the production Clears predicate.
for (var i = 0; i < poses.Count; i++)
for (var j = i + 1; j < poses.Count; j++)
{
var a = Bounds(poses[i]);
var b = Bounds(poses[j]);
var dx = System.Math.Max(0, System.Math.Max(a.Left - b.Right, b.Left - a.Right));
var dy = System.Math.Max(0, System.Math.Max(a.Bottom - b.Top, b.Bottom - a.Top));
Assert.True(System.Math.Sqrt(dx * dx + dy * dy) >= 0.25 - 0.00001,
$"independent material gap too small: {poses[i]} vs {poses[j]}");
}
(double Left, double Right, double Bottom, double Top) Bounds(NestJobPlacement pose)
{
var box = JobPartGeometry.Read(inserts.Single(p => p.Id == pose.PartId).Geometry).Bounds;
var corners = new[] { (box.Left, box.Bottom), (box.Right, box.Bottom),
(box.Right, box.Top), (box.Left, box.Top) };
var x = corners.Select(c => pose.X + c.Item1 * System.Math.Cos(pose.Rotation)
- c.Item2 * System.Math.Sin(pose.Rotation)).ToArray();
var y = corners.Select(c => pose.Y + c.Item1 * System.Math.Sin(pose.Rotation)
+ c.Item2 * System.Math.Cos(pose.Rotation)).ToArray();
return (x.Min(), x.Max(), y.Min(), y.Max());
}
}
}
@@ -75,6 +75,73 @@ public class NestPipelineCommitTests
Assert.Equal(2, Assert.Single(applied).Parts.Count);
}
[Fact]
public void DrawingChangedAfterValidationIsRefusedBeforeCommitEvenWithConsent()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(new PreMutationStub(), "stub",
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
Assert.True(result.IsValid, string.Join("; ", result.Violations));
drawing.Program = TestDrawingFactory.Rectangle(100, 100);
Assert.Throws<InvalidOperationException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(result, manager, allowInvalid: true));
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.Equal(0, drawing.Quantity.Nested);
}
private sealed class PreMutationStub : INestingEngine
{
public NestJobResult Solve(NestJob job, IProgress<NestJobProgress>? progress = null,
CancellationToken token = default)
{
var builder = new NestJobResultBuilder(job);
builder.AddSheet(job.Plates[0], new[] { (job.Parts[0].Id, 1.0, 1.0, 0.0) });
return builder.Build(NestJobStopReason.Completed);
}
}
private sealed class CancelDuringSnapshot(double x, double y, CancellationTokenSource source)
: OpenNest.CNC.LinearMove(x, y)
{
public override OpenNest.CNC.CodeType Type
{
get
{
source.Cancel();
return base.Type;
}
}
}
[Fact]
public void CancellationDuringFreshnessScanDoesNotMutateNest()
{
var drawing = new Drawing("part", TestDrawingFactory.Rectangle());
var nest = new Nest();
nest.Drawings.Add(drawing);
var empty = nest.CreatePlate();
using var manager = new PlateManager(nest);
var result = NestPipeline.Run(new PreMutationStub(), "stub",
new NestPipelineRequest("stub", new[] { new NestItem { Drawing = drawing, Quantity = 1 } },
new[] { new NestPlateStock("sheet", new Size(48, 96), 1, 0.25) }));
Assert.True(result.IsValid);
using var cts = new CancellationTokenSource();
var proposed = Assert.Single(Assert.Single(result.Plates).Parts);
proposed.Program.Codes[1] = new CancelDuringSnapshot(10, 0, cts);
Assert.Throws<OperationCanceledException>(() =>
NestPipelineCommit.ApplyToEmptyPlates(result, manager, token: cts.Token));
Assert.True(cts.IsCancellationRequested);
Assert.Same(empty, Assert.Single(nest.Plates));
Assert.Empty(empty.Parts);
Assert.Equal(0, drawing.Quantity.Nested);
}
[Fact]
public void CancelledCommitDoesNotCreateAnyPlate()
{
@@ -258,6 +258,44 @@ public class NestPipelineTests
);
}
[Fact]
public void DrawingChangedDuringSolveCannotBindGeometryOtherThanTheValidatedSnapshot()
{
var item = Item("bracket", 1);
var result = NestPipeline.Run(new StubEngine(job =>
{
item.Drawing.Program = TestDrawingFactory.Rectangle(100, 100);
return OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0));
}), "Mutator", Request("Mutator", item));
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("changed") && v.Contains("bracket"));
}
private sealed class TransientCloneMove(double x, double y) : LinearMove(x, y)
{
public override ICode Clone()
{
var before = EndPoint;
EndPoint = new Vector(100, 100);
try { return base.Clone(); }
finally { EndPoint = before; }
}
}
[Fact]
public void BindingTransientDrawingEditCannotReturnOrCommitUncheckedGeometry()
{
var item = Item("bracket", 1);
item.Drawing.Program.Codes[^1] = new TransientCloneMove(0, 0);
var result = NestPipeline.Run(new StubEngine(job =>
OnePlate(job, new NestJobPlacement(job.Parts[0].Id, 0, 1, 1, 0))),
"Transient", Request("Transient", item));
Assert.False(result.CanKeep);
Assert.Empty(result.Plates);
Assert.Contains(result.Violations, v => v.Contains("Bound part") && v.Contains("bracket"));
}
[Fact]
public void CancellationPropagatesWithoutAResult()
{