mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-24 13:51:06 -04:00
Merge branch 'master' of https://github.com/ajisaacs/OpenNest
This commit is contained in:
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()
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user