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); + } +}