diff --git a/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
new file mode 100644
index 0000000..6621a6c
--- /dev/null
+++ b/OpenNest.Engine/CuttingPlanning/CuttingPlanBatch.cs
@@ -0,0 +1,314 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.CNC.CuttingStrategy;
+
+namespace OpenNest.Engine.CuttingPlanning;
+
+/// What a batch is doing for one plate, reported from the worker.
+public enum CuttingPlanPhase
+{
+ /// Searching for a new whole-part order.
+ Reordering,
+
+ /// Planning with the plate's current part order.
+ KeepingOrder,
+}
+
+/// Worker progress: zero-based position in the batch, the plate's display number and phase.
+public sealed record CuttingPlanProgress(int PlateIndex, int PlateCount, int PlateNumber, CuttingPlanPhase Phase);
+
+///
+/// One desktop planning attempt over one or more plates with caller-confirmed cutting parameters.
+/// Capture on the thread that owns the plates, on a worker, then apply the
+/// returned proposal back on the owner thread. Every plate goes through
+/// ; nothing here installs programs or reorders parts itself.
+///
+public sealed class CuttingPlanBatch
+{
+ /// Budget of a free-order search, the service default.
+ public const int ReorderExpansionBudget = 20000;
+
+ ///
+ /// Expansions allowed per part when the order is kept. Measured near 210 per part on a dense
+ /// grid; the margin keeps 100-150 part plates inside the budget.
+ ///
+ public const int KeepOrderExpansionsPerPart = 400;
+
+ private readonly Entry[] entries;
+ private readonly CuttingParameters ownedParameters;
+
+ private CuttingPlanBatch(Entry[] entries, CuttingParameters ownedParameters)
+ {
+ this.entries = entries;
+ this.ownedParameters = ownedParameters;
+ }
+
+ public int PlateCount => entries.Length;
+
+ ///
+ /// Captures every plate's exact state with an owned copy of .
+ /// When the order may change, the current-order request is captured too, so a free search that
+ /// runs out of budget can be retried on the worker without reading live plates again.
+ ///
+ public static CuttingPlanBatch Capture(IReadOnlyList plates, CuttingParameters confirmedParameters,
+ bool preservePartOrder, IReadOnlyList plateNumbers = null, CancellationToken token = default) =>
+ Capture(plates, confirmedParameters, preservePartOrder, plateNumbers, ReorderExpansionBudget, token);
+
+ internal static CuttingPlanBatch Capture(IReadOnlyList plates, CuttingParameters confirmedParameters,
+ bool preservePartOrder, IReadOnlyList plateNumbers, int reorderBudget, CancellationToken token)
+ {
+ ArgumentNullException.ThrowIfNull(plates);
+ ArgumentNullException.ThrowIfNull(confirmedParameters);
+ if (plates.Count == 0)
+ throw new ArgumentException("At least one plate is required.", nameof(plates));
+ if (plates.Any(plate => plate == null)
+ || plates.Distinct(ReferenceEqualityComparer.Instance).Count() != plates.Count)
+ throw new ArgumentException("Plates must be distinct and not null.", nameof(plates));
+ if (plateNumbers != null && plateNumbers.Count != plates.Count)
+ throw new ArgumentException("Give one display number per plate.", nameof(plateNumbers));
+
+ CuttingParameters owned;
+ try
+ {
+ owned = OwnedCuttingParameters.Copy(confirmedParameters);
+ }
+ catch (NotSupportedException)
+ {
+ owned = null; // Each plate's capture reports the unsupported settings itself.
+ }
+
+ var entries = new Entry[plates.Count];
+ for (var index = 0; index < plates.Count; index++)
+ {
+ var plate = plates[index];
+ var keepOrderBudget = KeepOrderBudget(plate.Parts.Count);
+ var keepOrder = CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
+ expansionBudget: keepOrderBudget, confirmedParameters: confirmedParameters,
+ preservePartOrder: true), token);
+ var reorder = preservePartOrder ? null : CuttingPlanService.Capture(CuttingPlanRequest.ForPlate(plate,
+ expansionBudget: reorderBudget, confirmedParameters: confirmedParameters), token);
+ entries[index] = new(plate, plateNumbers?[index] ?? index + 1, reorder, keepOrder);
+ }
+ return new(entries, owned);
+ }
+
+ internal static int KeepOrderBudget(int partCount) =>
+ (int)System.Math.Min(int.MaxValue,
+ System.Math.Max((long)ReorderExpansionBudget, (long)partCount * KeepOrderExpansionsPerPart));
+
+ ///
+ /// Plans every plate from its captured snapshot. Safe on a worker: live plates are not read.
+ /// A free search that ends without a complete plan within its budget is retried with the
+ /// current order; the proposal reports that it kept the order.
+ ///
+ public CuttingPlanProposal Plan(IProgress progress = null,
+ CancellationToken token = default)
+ {
+ var plans = new CuttingPlanPlateResult[entries.Length];
+ for (var index = 0; index < entries.Length; index++)
+ {
+ var entry = entries[index];
+ CuttingPlanResult reorder = null;
+ if (entry.Reorder != null)
+ {
+ progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.Reordering));
+ reorder = CuttingPlanService.Plan(entry.Reorder, token);
+ if (reorder.Status != CuttingPlanStatus.NoSolutionWithinBudget)
+ {
+ plans[index] = new(entry.Plate, entry.Number, reorder, null);
+ continue;
+ }
+ }
+ progress?.Report(new(index, entries.Length, entry.Number, CuttingPlanPhase.KeepingOrder));
+ plans[index] = new(entry.Plate, entry.Number, CuttingPlanService.Plan(entry.KeepOrder, token), reorder);
+ }
+ return new(plans, ownedParameters);
+ }
+
+ private sealed record Entry(Plate Plate, int Number, CuttingPlanSnapshot Reorder, CuttingPlanSnapshot KeepOrder);
+}
+
+/// One plate's outcome inside a .
+public sealed class CuttingPlanPlateResult
+{
+ internal CuttingPlanPlateResult(Plate plate, int plateNumber, CuttingPlanResult result,
+ CuttingPlanResult reorderAttempt)
+ {
+ Plate = plate;
+ PlateNumber = plateNumber;
+ Result = result;
+ ReorderAttempt = reorderAttempt;
+ }
+
+ /// The live plate. Read it only on the thread that owns it.
+ public Plate Plate { get; }
+ public int PlateNumber { get; }
+
+ /// The result that would be applied.
+ public CuttingPlanResult Result { get; }
+
+ /// The free-order attempt that ran out of budget before the current order was kept; else null.
+ public CuttingPlanResult ReorderAttempt { get; }
+
+ public bool KeptCurrentOrder => ReorderAttempt != null;
+ public bool IsReady => Result.Status == CuttingPlanStatus.Ready && Result.IndependentlyReplayed;
+ public int PartCount => Result.ProposedOrder.Count;
+ public int RegeneratedCount => Result.ProposedOrder.Count(p => p.IsRegenerated);
+
+ public bool OrderChanged =>
+ Result.ProposedOrder.Select((proposal, index) => proposal.SourceOrdinal != index).Any(changed => changed);
+}
+
+///
+/// The outcome of a batch. Apply is all-or-nothing and is offered only when every plate is ready.
+///
+public sealed class CuttingPlanProposal
+{
+ private const int FindingsPerPlate = 5;
+ private readonly CuttingParameters ownedParameters;
+
+ internal CuttingPlanProposal(IReadOnlyList plates, CuttingParameters ownedParameters)
+ {
+ Plates = plates;
+ this.ownedParameters = ownedParameters;
+ }
+
+ public IReadOnlyList Plates { get; }
+
+ public bool IsCancelled => Plates.Any(p => p.Result.Status == CuttingPlanStatus.Cancelled);
+
+ public bool CanApply => Plates.Count > 0 && !IsCancelled && ownedParameters != null
+ && Plates.All(p => p.IsReady);
+
+ ///
+ /// Installs every plate's replayed proposal through , on the
+ /// thread that owns the plates. Only after every plate is applied does each one keep an owned copy
+ /// of the confirmed parameters as its cutting settings; any other status changes nothing.
+ ///
+ public CuttingCommitResult Apply(CancellationToken token = default)
+ {
+ if (!CanApply)
+ return new(CuttingCommitStatus.InvalidInput,
+ "Every plate must have a ready plan before anything is applied.");
+ var commit = CuttingPlanService.Apply(Plates.Select(p => p.Result), token);
+ if (commit.Status == CuttingCommitStatus.Applied)
+ foreach (var plate in Plates)
+ plate.Plate.CuttingParameters = OwnedCuttingParameters.Copy(ownedParameters);
+ return commit;
+ }
+
+ ///
+ /// A detached copy of one plate for display: ready plates show the proposed order and programs,
+ /// others the current parts in their current order (finding part numbers refer to it). The copy
+ /// has quantity zero, so adding its parts leaves drawing quantities unchanged.
+ ///
+ public Plate BuildPreview(int index)
+ {
+ var planned = Plates[index];
+ var source = planned.Plate;
+ var preview = new Plate(source.Size)
+ {
+ Quantity = 0,
+ Quadrant = source.Quadrant,
+ PartSpacing = source.PartSpacing,
+ EdgeSpacing = source.EdgeSpacing,
+ };
+ if (!planned.IsReady)
+ {
+ foreach (var part in source.Parts)
+ preview.Parts.Add((Part)part.Clone());
+ return preview;
+ }
+ foreach (var proposal in planned.Result.ProposedOrder)
+ {
+ var part = (Part)proposal.SourcePart.Clone();
+ part.RestoreLeadInProgram(proposal.CopyProgram(), proposal.LeadInsLocked);
+ preview.Parts.Add(part);
+ }
+ return preview;
+ }
+
+ /// Operator-facing summary: one overall line, then each plate and its findings.
+ public IReadOnlyList Describe(string unit)
+ {
+ var lines = new List();
+ var ready = Plates.Count(p => p.IsReady);
+ if (IsCancelled)
+ lines.Add("Planning was cancelled. Nothing has changed.");
+ else if (CanApply)
+ lines.Add($"Ready to apply to {Count(Plates.Count, "plate")}: "
+ + $"{Count(Plates.Count(p => p.OrderChanged), "plate")} with a new part order, "
+ + $"{Count(Plates.Sum(p => p.RegeneratedCount), "part program")} regenerated.");
+ else
+ lines.Add($"Apply is unavailable: {Plates.Count - ready} of {Count(Plates.Count, "plate")} could not "
+ + "be planned. No plate changes until every plate is ready.");
+
+ foreach (var plate in Plates)
+ {
+ lines.Add(string.Empty);
+ lines.AddRange(DescribePlate(plate, unit));
+ }
+ return lines;
+ }
+
+ private static IEnumerable DescribePlate(CuttingPlanPlateResult plate, string unit)
+ {
+ var heading = $"Plate {plate.PlateNumber}: ";
+ var result = plate.Result;
+ if (plate.IsReady && plate.PartCount == 0)
+ {
+ yield return heading + "no parts.";
+ yield break;
+ }
+ if (plate.IsReady)
+ {
+ var kept = plate.PartCount - plate.RegeneratedCount;
+ var order = plate.KeptCurrentOrder
+ ? "No new part order was found within the search limit, so the current order is kept."
+ : plate.OrderChanged ? "The part order changes." : "The part order is unchanged.";
+ yield return heading + $"ready. {Count(plate.PartCount, "part")}, {plate.RegeneratedCount} "
+ + $"regenerated, {kept} kept as is; rapid travel "
+ + $"{result.RapidDistance.ToString("0.##", CultureInfo.CurrentCulture)} {unit}. {order}";
+ yield break;
+ }
+
+ yield return heading + StatusText(result.Status);
+ if (plate.KeptCurrentOrder)
+ yield return " No new part order was found within the search limit, and planning with the "
+ + "current order was refused:";
+ foreach (var finding in result.Findings.Take(FindingsPerPlate))
+ yield return " - " + DescribeFinding(finding);
+ if (result.Findings.Count > FindingsPerPlate)
+ yield return $" ... and {result.Findings.Count - FindingsPerPlate} more.";
+ }
+
+ private static string StatusText(CuttingPlanStatus status) => status switch
+ {
+ CuttingPlanStatus.Ready => "ready, but the plan was not independently replayed.",
+ CuttingPlanStatus.ConstraintConflict => "blocked: the fixed programs or required cut order conflict.",
+ CuttingPlanStatus.UnsupportedGeometry => "blocked: unsupported geometry or an incomplete check.",
+ CuttingPlanStatus.InvalidInput => "blocked: invalid input.",
+ CuttingPlanStatus.NoSolutionWithinBudget => "no complete plan was found within the search limit.",
+ CuttingPlanStatus.Cancelled => "cancelled.",
+ _ => status.ToString(),
+ };
+
+ private static string DescribeFinding(CuttingPlanFinding finding)
+ {
+ var text = finding.SourceOrdinal is int ordinal
+ ? $"Part {ordinal + 1}{Name(finding.SourcePart)}: {finding.Message}"
+ : finding.Message;
+ if (finding.OtherSourceOrdinal is int other)
+ text += $" (with part {other + 1}{Name(finding.OtherSourcePart)})";
+ return text;
+ }
+
+ private static string Name(Part part) =>
+ string.IsNullOrWhiteSpace(part?.BaseDrawing?.Name) ? string.Empty : $" ({part.BaseDrawing.Name})";
+
+ private static string Count(int count, string noun) => $"{count} {noun}{(count == 1 ? string.Empty : "s")}";
+}
diff --git a/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs
new file mode 100644
index 0000000..a50a84d
--- /dev/null
+++ b/OpenNest.Tests/CuttingPlanning/CuttingPlanBatchTests.cs
@@ -0,0 +1,232 @@
+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);
+ locked.LeadInsLocked = true; // Locked programs never regenerate: no lead-in is a conflict.
+ 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_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();
+
+ 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.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_IsDetachedAndShowsTheProposalOrTheCurrentParts()
+ {
+ var nest = new Nest();
+ var plate = Plate(nest, Grid(4));
+ plate.Quantity = 3;
+ var locked = Clean("locked", 1, 1);
+ locked.LeadInsLocked = true;
+ var blocked = Plate(nest, locked, Clean("free", 12, 1));
+ 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, blocked], ExplicitContourTests.Parameters(), false).Plan();
+
+ var preview = proposal.BuildPreview(0);
+ var current = proposal.BuildPreview(1);
+
+ 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));
+ Assert.Equal(blocked.Parts.Select(p => p.Location), current.Parts.Select(p => p.Location));
+ Assert.All(current.Parts, part => Assert.DoesNotContain(part, blocked.Parts));
+ }
+
+ [Theory]
+ [InlineData(0, 20000)]
+ [InlineData(10, 20000)]
+ [InlineData(100, 40000)]
+ [InlineData(6_000_000, int.MaxValue)]
+ public void KeepOrderBudget_ScalesWithPartCountFromTheDefault(int parts, int expected) =>
+ Assert.Equal(expected, CuttingPlanBatch.KeepOrderBudget(parts));
+
+ [Fact]
+ public void Capture_RejectsMissingDuplicateOrMisnumberedPlates()
+ {
+ var nest = new Nest();
+ var plate = Plate(nest, Clean("a", 1, 1));
+ var parameters = ExplicitContourTests.Parameters();
+
+ Assert.Throws(() => CuttingPlanBatch.Capture(null, parameters, false));
+ Assert.Throws(() => CuttingPlanBatch.Capture([plate], null, false));
+ Assert.Throws(() => CuttingPlanBatch.Capture([], parameters, false));
+ Assert.Throws(() => CuttingPlanBatch.Capture([plate, plate], parameters, false));
+ Assert.Throws(() => CuttingPlanBatch.Capture([plate, null], parameters, false));
+ Assert.Throws(() => 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 phases) : IProgress
+ {
+ public void Report(CuttingPlanProgress value) => phases.Add(value.Phase);
+ }
+}