mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-21 10:51:37 -04:00
401 lines
18 KiB
C#
401 lines
18 KiB
C#
using OpenNest.CNC;
|
|
using OpenNest.CNC.CuttingPlanning;
|
|
using OpenNest.CNC.CuttingStrategy;
|
|
using OpenNest.Engine.CuttingPlanning;
|
|
using OpenNest.Geometry;
|
|
|
|
namespace OpenNest.Tests.CuttingPlanning;
|
|
|
|
public class CuttingPlanBatchTests
|
|
{
|
|
[Fact]
|
|
public void Apply_EveryPlateReady_InstallsAllPlatesAndRecordsOwnedSettingsOnEach()
|
|
{
|
|
var nest = new Nest();
|
|
var first = Plate(nest, Clean("a", 1, 1), Clean("b", 12, 1));
|
|
var second = Plate(nest, Clean("c", 1, 1));
|
|
var parameters = ExplicitContourTests.Parameters();
|
|
var length = ((LineLeadIn)parameters.ExternalLeadIn).Length;
|
|
|
|
var proposal = CuttingPlanBatch.Capture([first, second], parameters, preservePartOrder: false).Plan();
|
|
|
|
Assert.True(proposal.CanApply);
|
|
Assert.All(proposal.Plates, p => Assert.True(p.IsReady));
|
|
Assert.Equal(3, proposal.Plates.Sum(p => p.RegeneratedCount));
|
|
Assert.All(first.Parts.Concat(second.Parts), part => Assert.False(part.HasManualLeadIns)); // Planning never mutates.
|
|
((LineLeadIn)parameters.ExternalLeadIn).Length = length * 3; // Confirmed settings were copied at capture.
|
|
|
|
var commit = proposal.Apply();
|
|
|
|
Assert.Equal(CuttingCommitStatus.Applied, commit.Status);
|
|
foreach (var planned in proposal.Plates)
|
|
foreach (var placement in planned.Result.ProposedOrder)
|
|
Assert.True(ProgramContent.Equal(placement.CopyProgram(), placement.SourcePart.Program));
|
|
Assert.All(first.Parts.Concat(second.Parts), part => Assert.True(part.HasManualLeadIns));
|
|
Assert.NotNull(first.CuttingParameters);
|
|
Assert.NotSame(parameters, first.CuttingParameters);
|
|
Assert.NotSame(first.CuttingParameters, second.CuttingParameters);
|
|
Assert.Equal(length, ((LineLeadIn)first.CuttingParameters.ExternalLeadIn).Length);
|
|
Assert.Equal(length, ((LineLeadIn)second.CuttingParameters.ExternalLeadIn).Length);
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_OneBlockedPlate_RefusesTheWholeBatchAndNamesThePlateAndPart()
|
|
{
|
|
var nest = new Nest();
|
|
var ready = Plate(nest, Clean("open", 1, 1));
|
|
var locked = Clean("locked", 1, 1);
|
|
var crossing = OwnedProgramCopy.Copy(locked.Program);
|
|
crossing.MoveTo(5, 5); // Rapid enters the already completed square.
|
|
Assert.True(locked.RestoreLeadInProgram(crossing, true));
|
|
var blocked = Plate(nest, locked);
|
|
var readyProgram = ready.Parts[0].Program;
|
|
|
|
var proposal = CuttingPlanBatch.Capture([ready, blocked], ExplicitContourTests.Parameters(),
|
|
preservePartOrder: false, plateNumbers: [3, 7]).Plan();
|
|
|
|
Assert.False(proposal.CanApply);
|
|
Assert.True(proposal.Plates[0].IsReady);
|
|
var refused = proposal.Plates[1];
|
|
Assert.Equal(CuttingPlanStatus.ConstraintConflict, refused.Result.Status);
|
|
Assert.False(refused.KeptCurrentOrder); // Only a budget-exhausted search is retried.
|
|
var text = string.Join("\n", proposal.Describe("in"));
|
|
Assert.StartsWith("Apply is unavailable: 1 of 2 plates", text);
|
|
Assert.Contains("Plate 3: ready.", text);
|
|
Assert.Contains("Plate 7: blocked", text);
|
|
Assert.Contains("- Part 1 (locked):", text);
|
|
|
|
var commit = proposal.Apply();
|
|
|
|
Assert.Equal(CuttingCommitStatus.InvalidInput, commit.Status);
|
|
Assert.Same(readyProgram, ready.Parts[0].Program);
|
|
Assert.False(ready.Parts[0].HasManualLeadIns);
|
|
Assert.Null(ready.CuttingParameters);
|
|
Assert.Null(blocked.CuttingParameters);
|
|
}
|
|
|
|
[Fact]
|
|
public void Plan_OverlappingParts_BlocksApplyAndNamesBothParts()
|
|
{
|
|
var nest = new Nest();
|
|
var plate = Plate(nest, Clean("first", 1, 1), Clean("second", 6, 6));
|
|
var programs = plate.Parts.Select(p => p.Program).ToArray();
|
|
|
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
|
|
|
var planned = Assert.Single(proposal.Plates);
|
|
Assert.Equal(CuttingPlanStatus.Ready, planned.Result.Status); // The route alone is fine...
|
|
var pair = Assert.Single(planned.Overlap.Pairs);
|
|
Assert.Equal((0, 1), (pair.PartAId, pair.PartBId));
|
|
Assert.False(planned.IsReady); // ...but overlapping material blocks the plate.
|
|
Assert.False(proposal.CanApply);
|
|
var text = string.Join("\n", proposal.Describe("in"));
|
|
Assert.Contains("Plate 1: blocked: parts overlap or could not be checked for overlap.", text);
|
|
Assert.Contains("- Part 1 (first) overlaps part 2 (second).", text);
|
|
var row = Assert.Single(proposal.DiagnosticRows());
|
|
Assert.Equal((0, 1, 1, 2),
|
|
(row.PlateIndex, row.PlateNumber, row.PartNumber, row.OtherPartNumber));
|
|
Assert.Contains("overlaps part 2", row.Message);
|
|
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
|
|
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
|
|
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(Mode.Absolute)]
|
|
[InlineData(Mode.Incremental)]
|
|
public void Plan_SubprogramHoles_AreNeverAFalseOverlap(Mode mode)
|
|
{
|
|
var hole = new Program();
|
|
hole.MoveTo(1, 0);
|
|
hole.Codes.Add(new ArcMove(1, 0, 0, 0) { Rotation = RotationType.CCW });
|
|
hole.Mode = mode;
|
|
var clean = ExplicitContourTests.Square(false);
|
|
clean.SubPrograms[-1] = hole;
|
|
clean.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(3, 3), Id = -1 });
|
|
clean.Codes.Add(new SubProgramCall { Program = hole, Offset = new Vector(7, 3), Id = -1 });
|
|
clean.Mode = mode;
|
|
var nest = new Nest();
|
|
var plate = Plate(nest, new Part(new Drawing("holes", clean), new Vector(1, 1)));
|
|
|
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
|
|
|
var planned = Assert.Single(proposal.Plates);
|
|
Assert.True(planned.IsRouteReady);
|
|
Assert.Empty(planned.Overlap.Pairs);
|
|
if (mode == Mode.Incremental)
|
|
Assert.True(planned.IsReady, string.Join("; ", planned.Overlap.Issues.Select(i => i.Message)));
|
|
else
|
|
{
|
|
// The overlap check cannot read absolute subprograms yet: blocked as unchecked, not as overlapping.
|
|
Assert.False(planned.IsReady);
|
|
Assert.Contains("Overlap check incomplete for part 1: Absolute-mode subprograms",
|
|
string.Join("\n", proposal.Describe("in")));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Capture_RefusedGraphs_BlockThePlateInsteadOfThrowing()
|
|
{
|
|
var nest = new Nest();
|
|
var missing = Clean("missing", 1, 1);
|
|
missing.BaseDrawing.Program.Codes = null;
|
|
var plate = Plate(nest, missing);
|
|
|
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
|
|
|
var planned = Assert.Single(proposal.Plates);
|
|
Assert.False(planned.IsReady);
|
|
Assert.False(proposal.CanApply);
|
|
Assert.Null(proposal.BuildPreview(0));
|
|
}
|
|
|
|
[Fact]
|
|
public void PlanReorder_ShortPassReady_RetainsReplayedApplicableProposal()
|
|
{
|
|
var plate = Plate(new Nest(), Clean("first", 1, 1), Clean("second", 12, 1));
|
|
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
|
var full = CuttingPlanService.Plan(snapshot);
|
|
var result = CuttingPlanBatch.PlanReorder(snapshot, CancellationToken.None);
|
|
|
|
Assert.Equal(CuttingPlanStatus.Ready, result.Status);
|
|
Assert.True(result.IndependentlyReplayed);
|
|
Assert.True(result.Expansions < full.Expansions,
|
|
$"Short pass {result.Expansions}, full {full.Expansions}");
|
|
Assert.InRange(result.Expansions, 1, CuttingPlanBatch.FirstPassBudget - 1);
|
|
Assert.Equal(2, result.ProposedOrder.Count);
|
|
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
|
|
Assert.Equal(CuttingCommitStatus.Applied, CuttingPlanService.Apply([result]).Status);
|
|
}
|
|
|
|
[Fact]
|
|
public void PlanReorder_ShortPassExhausted_UsesFullCapAndOriginalBudget()
|
|
{
|
|
var plate = Plate(new Nest(), Grid(4));
|
|
var snapshot = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
|
|
confirmedParameters: ExplicitContourTests.Parameters()));
|
|
var full = CuttingPlanService.Plan(snapshot);
|
|
var recovered = CuttingPlanBatch.PlanReorder(snapshot, CancellationToken.None, firstPassBudget: 1);
|
|
|
|
Assert.Equal(CuttingPlanStatus.Ready, full.Status);
|
|
Assert.Equal(CuttingPlanStatus.Ready, recovered.Status);
|
|
Assert.True(recovered.IndependentlyReplayed);
|
|
Assert.Equal(full.Expansions, recovered.Expansions);
|
|
Assert.Equal(full.RapidDistance, recovered.RapidDistance);
|
|
Assert.Equal(full.ProposedOrder.Select(p => p.SourceOrdinal), recovered.ProposedOrder.Select(p => p.SourceOrdinal));
|
|
for (var i = 0; i < full.ProposedOrder.Count; i++)
|
|
Assert.True(ProgramContent.Equal(full.ProposedOrder[i].CopyProgram(), recovered.ProposedOrder[i].CopyProgram()));
|
|
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
|
|
}
|
|
|
|
[Fact]
|
|
public void PlanReorder_CancelledDuringShortPass_DoesNotRetryOrMutate()
|
|
{
|
|
var plate = Plate(new Nest(), Clean("first", 1, 1), Clean("second", 12, 1));
|
|
using var cancellation = new CancellationTokenSource();
|
|
var observations = 0;
|
|
var request = new CuttingPlanRequest(plate.Parts, confirmedParameters: ExplicitContourTests.Parameters())
|
|
{ ExpansionObserver = _ => { observations++; cancellation.Cancel(); } };
|
|
var snapshot = CuttingPlanService.Capture(request);
|
|
|
|
var result = CuttingPlanBatch.PlanReorder(snapshot, cancellation.Token);
|
|
|
|
Assert.Equal(CuttingPlanStatus.Cancelled, result.Status);
|
|
Assert.Equal(1, observations);
|
|
Assert.Empty(result.ProposedOrder);
|
|
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
|
|
}
|
|
|
|
[Fact]
|
|
public void Plan_FreeSearchOutOfBudget_KeepsTheCurrentOrderAndSaysSo()
|
|
{
|
|
var nest = new Nest();
|
|
var plate = Plate(nest, Grid(4));
|
|
var order = plate.Parts.ToArray();
|
|
|
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false, null,
|
|
reorderBudget: 50, CancellationToken.None).Plan();
|
|
|
|
var planned = Assert.Single(proposal.Plates);
|
|
Assert.True(planned.KeptCurrentOrder);
|
|
Assert.Equal(CuttingPlanStatus.NoSolutionWithinBudget, planned.ReorderAttempt!.Status);
|
|
Assert.True(planned.IsReady);
|
|
Assert.False(planned.OrderChanged);
|
|
Assert.Equal(order, planned.Result.ProposedOrder.Select(p => p.SourcePart));
|
|
Assert.Contains("the current order is kept", string.Join("\n", proposal.Describe("in")));
|
|
Assert.True(proposal.CanApply);
|
|
}
|
|
|
|
[Fact]
|
|
public void Plan_PreservedOrder_DoesNotSearchForANewOrder()
|
|
{
|
|
var nest = new Nest();
|
|
var plate = Plate(nest, Grid(4));
|
|
var phases = new List<CuttingPlanPhase>();
|
|
|
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), preservePartOrder: true)
|
|
.Plan(new Recorder(phases));
|
|
|
|
var planned = Assert.Single(proposal.Plates);
|
|
Assert.True(planned.IsReady);
|
|
Assert.False(planned.KeptCurrentOrder);
|
|
Assert.Equal(new[] { CuttingPlanPhase.CheckingOverlap, CuttingPlanPhase.KeepingOrder }, phases);
|
|
}
|
|
|
|
[Fact]
|
|
public void Apply_AfterALiveEdit_IsStaleAndChangesNothing()
|
|
{
|
|
var nest = new Nest();
|
|
var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 12, 1));
|
|
var programs = plate.Parts.Select(p => p.Program).ToArray();
|
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
|
Assert.True(proposal.CanApply);
|
|
|
|
plate.Parts[1].Offset(0, 1);
|
|
var commit = proposal.Apply();
|
|
|
|
Assert.Equal(CuttingCommitStatus.Stale, commit.Status);
|
|
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
|
|
Assert.All(plate.Parts, part => Assert.False(part.HasManualLeadIns));
|
|
Assert.Null(plate.CuttingParameters);
|
|
}
|
|
|
|
[Fact]
|
|
public void Plan_Cancelled_OffersNothingToApply()
|
|
{
|
|
var nest = new Nest();
|
|
var plate = Plate(nest, Clean("a", 1, 1));
|
|
var batch = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false);
|
|
using var cancellation = new CancellationTokenSource();
|
|
cancellation.Cancel();
|
|
|
|
var proposal = batch.Plan(token: cancellation.Token);
|
|
|
|
Assert.True(proposal.IsCancelled);
|
|
Assert.False(proposal.CanApply);
|
|
Assert.Equal("Planning was cancelled. Nothing has changed.", proposal.Describe("in")[0]);
|
|
Assert.Equal(CuttingCommitStatus.InvalidInput, proposal.Apply().Status);
|
|
Assert.False(plate.Parts[0].HasManualLeadIns);
|
|
Assert.Null(plate.CuttingParameters);
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildPreview_ReadyPlate_IsDetachedAndShowsTheProposal()
|
|
{
|
|
var nest = new Nest();
|
|
var plate = Plate(nest, Grid(4));
|
|
plate.Quantity = 3;
|
|
var drawing = plate.Parts[0].BaseDrawing;
|
|
var nested = drawing.Quantity.Nested;
|
|
var liveParts = plate.Parts.ToArray();
|
|
var livePrograms = liveParts.Select(p => p.Program).ToArray();
|
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
|
|
|
var preview = proposal.BuildPreview(0);
|
|
|
|
Assert.Equal(0, preview.Quantity);
|
|
Assert.Equal(plate.Size, preview.Size);
|
|
Assert.Equal(nested, drawing.Quantity.Nested);
|
|
var proposed = proposal.Plates[0].Result.ProposedOrder;
|
|
Assert.Equal(proposed.Count, preview.Parts.Count);
|
|
for (var i = 0; i < proposed.Count; i++)
|
|
{
|
|
Assert.DoesNotContain(preview.Parts[i], liveParts);
|
|
Assert.Same(proposed[i].SourcePart.BaseDrawing, preview.Parts[i].BaseDrawing);
|
|
Assert.Equal(proposed[i].Location, preview.Parts[i].Location);
|
|
Assert.True(ProgramContent.Equal(proposed[i].CopyProgram(), preview.Parts[i].Program));
|
|
}
|
|
Assert.Equal(liveParts, plate.Parts);
|
|
Assert.Equal(livePrograms, plate.Parts.Select(p => p.Program));
|
|
}
|
|
|
|
[Fact]
|
|
public void BuildPreview_NotReadyOrChangedPlate_ShowsNothing()
|
|
{
|
|
var nest = new Nest();
|
|
var ready = Plate(nest, Clean("a", 1, 1));
|
|
var locked = Clean("locked", 1, 1);
|
|
var crossing = OwnedProgramCopy.Copy(locked.Program);
|
|
crossing.MoveTo(5, 5); // Rapid enters the already completed square.
|
|
Assert.True(locked.RestoreLeadInProgram(crossing, true));
|
|
var blocked = Plate(nest, locked);
|
|
var proposal = CuttingPlanBatch.Capture([ready, blocked], ExplicitContourTests.Parameters(), false).Plan();
|
|
Assert.True(proposal.Plates[0].IsReady);
|
|
Assert.False(proposal.Plates[1].IsReady);
|
|
|
|
// A refused plate may hold graphs that are unsafe to copy, so it is never previewed.
|
|
Assert.Null(proposal.BuildPreview(1));
|
|
Assert.NotNull(proposal.BuildPreview(0));
|
|
|
|
// A moved part would draw the replayed program somewhere it was never checked.
|
|
ready.Parts[0].Offset(25, 0);
|
|
Assert.Null(proposal.BuildPreview(0));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0, 20000)]
|
|
[InlineData(10, 20000)]
|
|
[InlineData(100, 40000)]
|
|
[InlineData(6_000_000, int.MaxValue)]
|
|
public void PlateBudget_ScalesWithPartCountFromTheDefault(int parts, int expected) =>
|
|
Assert.Equal(expected, CuttingPlanBatch.PlateBudget(parts));
|
|
|
|
[Fact]
|
|
public void Plan_LargeGrid_FindsANewOrderWithinThePlateBudget()
|
|
{
|
|
// Row-major 10 x 10 needs more than the 20000 default to reorder (about 26000).
|
|
var nest = new Nest();
|
|
var plate = Plate(nest, Grid(100));
|
|
plate.Size = new Size(120, 120);
|
|
|
|
var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), false).Plan();
|
|
|
|
var planned = Assert.Single(proposal.Plates);
|
|
Assert.True(planned.IsReady, string.Join("\n", proposal.Describe("in")));
|
|
Assert.False(planned.KeptCurrentOrder);
|
|
Assert.Equal(100, planned.PartCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Capture_RejectsMissingDuplicateOrMisnumberedPlates()
|
|
{
|
|
var nest = new Nest();
|
|
var plate = Plate(nest, Clean("a", 1, 1));
|
|
var parameters = ExplicitContourTests.Parameters();
|
|
|
|
Assert.Throws<ArgumentNullException>(() => CuttingPlanBatch.Capture(null, parameters, false));
|
|
Assert.Throws<ArgumentNullException>(() => CuttingPlanBatch.Capture([plate], null, false));
|
|
Assert.Throws<ArgumentException>(() => CuttingPlanBatch.Capture([], parameters, false));
|
|
Assert.Throws<ArgumentException>(() => CuttingPlanBatch.Capture([plate, plate], parameters, false));
|
|
Assert.Throws<ArgumentException>(() => CuttingPlanBatch.Capture([plate, null], parameters, false));
|
|
Assert.Throws<ArgumentException>(() => CuttingPlanBatch.Capture([plate], parameters, false, [1, 2]));
|
|
}
|
|
|
|
private static Plate Plate(Nest nest, params Part[] parts)
|
|
{
|
|
var plate = nest.CreatePlate();
|
|
plate.Size = new Size(100, 100);
|
|
foreach (var part in parts)
|
|
plate.Parts.Add(part);
|
|
return plate;
|
|
}
|
|
|
|
private static Part Clean(string name, double x, double y) =>
|
|
new(new Drawing(name, ExplicitContourTests.Square(false)), new Vector(x, y));
|
|
|
|
private static Part[] Grid(int count)
|
|
{
|
|
var drawing = new Drawing("grid", PreparedContourTests.Holes());
|
|
var side = (int)System.Math.Ceiling(System.Math.Sqrt(count));
|
|
return Enumerable.Range(0, count)
|
|
.Select(i => new Part(drawing, new Vector(1 + i % side * 11, 1 + i / side * 11)))
|
|
.ToArray();
|
|
}
|
|
|
|
private sealed class Recorder(List<CuttingPlanPhase> phases) : IProgress<CuttingPlanProgress>
|
|
{
|
|
public void Report(CuttingPlanProgress value) => phases.Add(value.Phase);
|
|
}
|
|
}
|