diff --git a/OpenNest.Core/AppliedCuttingWork.cs b/OpenNest.Core/AppliedCuttingWork.cs new file mode 100644 index 00000000..0a1acc98 --- /dev/null +++ b/OpenNest.Core/AppliedCuttingWork.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace OpenNest; + +/// Existing cutting work on a set of plates: applied lead-ins and cut-offs. +/// Real parts carrying applied lead-ins (locked ones included). +/// Of those, parts whose lead-ins are locked. +/// Cut-off definitions. +/// Display numbers of the plates holding any of it, ascending. +public sealed record AppliedCuttingWorkSummary(int LeadInParts, int LockedLeadInParts, int CutOffs, + IReadOnlyList PlateNumbers) +{ + public bool HasExistingWork => LeadInParts > 0 || CutOffs > 0; + + /// Operator text for the cutting planner's warning banner; empty when nothing exists. + public string Describe() + { + if (!HasExistingWork) + return string.Empty; + var found = new List(); + if (LeadInParts > 0) + found.Add($"lead-ins on {Count(LeadInParts, "part")}" + + (LockedLeadInParts > 0 ? $" ({LockedLeadInParts} locked)" : string.Empty)); + if (CutOffs > 0) + found.Add(Count(CutOffs, "cut-off")); + var plates = PlateNumbers.Count == 1 ? "plate " : "plates "; + var kept = new List(); + if (LockedLeadInParts > 0) + kept.Add("locked lead-ins"); + if (CutOffs > 0) + kept.Add("existing cut-offs"); + return $"ALREADY APPLIED: {string.Join(" and ", found)} on {plates}{string.Join(", ", PlateNumbers)}." + + (LeadInParts > LockedLeadInParts ? " Planning recalculates unlocked lead-ins." : string.Empty) + + (kept.Count > 0 ? $" Planning keeps {string.Join(" and ", kept)} unchanged." : string.Empty) + + " Clear them to recalculate everything from scratch."; + } + + private static string Count(int value, string noun) => $"{value} {noun}{(value == 1 ? string.Empty : "s")}"; +} + +/// Finds and clears applied lead-ins and cut-offs so a plan can recalculate them. +public static class AppliedCuttingWork +{ + /// + /// Counts applied lead-ins on real parts and cut-off definitions. Definitions in + /// (for example ones staged by the caller) are not counted. + /// + public static AppliedCuttingWorkSummary Summarize(IReadOnlyList plates, + IReadOnlyList plateNumbers = null, IEnumerable ignoredCutOffs = null) + { + ArgumentNullException.ThrowIfNull(plates); + if (plateNumbers != null && plateNumbers.Count != plates.Count) + throw new ArgumentException("Give one display number per plate.", nameof(plateNumbers)); + var ignored = new HashSet(ignoredCutOffs ?? [], ReferenceEqualityComparer.Instance); + int leads = 0, locked = 0, cutOffs = 0; + var numbers = new SortedSet(); + for (var index = 0; index < plates.Count; index++) + { + var plate = plates[index] ?? throw new ArgumentException("Plates must not be null.", nameof(plates)); + var plateLeads = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray(); + var plateCutOffs = plate.CutOffs.Count(c => !ignored.Contains(c)); + leads += plateLeads.Length; + locked += plateLeads.Count(p => p.LeadInsLocked); + cutOffs += plateCutOffs; + if (plateLeads.Length > 0 || plateCutOffs > 0) + numbers.Add(plateNumbers?[index] ?? index + 1); + } + return new(leads, locked, cutOffs, numbers.ToArray()); + } + + /// + /// Removes every applied lead-in (locked ones too) from real parts and every cut-off with + /// its cut-off parts, and forgets each plate's recorded cutting parameters. Part poses, + /// order and quantities are unchanged. Returns what was cleared. + /// + public static AppliedCuttingWorkSummary Clear(IReadOnlyList plates, IReadOnlyList plateNumbers = null) + { + var cleared = Summarize(plates, plateNumbers); + foreach (var plate in plates) + { + foreach (var part in plate.Parts.Where(p => !p.BaseDrawing.IsCutOff && p.HasManualLeadIns).ToArray()) + part.RemoveLeadIns(); + plate.CutOffs.Clear(); + for (var index = plate.Parts.Count - 1; index >= 0; index--) + if (plate.Parts[index].BaseDrawing.IsCutOff) + plate.Parts.RemoveAt(index); + plate.CuttingParameters = null; + } + return cleared; + } +} diff --git a/OpenNest.Core/AutomaticCutOffBatch.cs b/OpenNest.Core/AutomaticCutOffBatch.cs index 1c04ae3a..6e429506 100644 --- a/OpenNest.Core/AutomaticCutOffBatch.cs +++ b/OpenNest.Core/AutomaticCutOffBatch.cs @@ -42,11 +42,19 @@ public static class AutomaticCutOffBatch /// Returned plans describe the fresh proposal, not preview parts to accept into a plate. /// public static IReadOnlyList Apply(IReadOnlyList plates, + AutomaticCutOffOptions options, CutOffSettings settings) => Stage(plates, options, settings).Plans; + + /// + /// Applies like , but keeps the recovery state so the caller can take the + /// new cut-offs back off again () when the work they + /// were added for is abandoned. A blocked batch changes nothing and has nothing to revert. + /// + public static AutomaticCutOffStaging Stage(IReadOnlyList plates, AutomaticCutOffOptions options, CutOffSettings settings) { var plans = Create(plates, options, settings); if (plans.Any(plan => plan.HasBlockingDiagnostics)) - return plans; + return new AutomaticCutOffStaging(plans, []); var restoreActions = new List<(int PlateNumber, Action Restore)>(); try @@ -66,21 +74,7 @@ public static class AutomaticCutOffBatch } catch (Exception applyError) { - var rollbackErrors = new List(); - for (var index = restoreActions.Count - 1; index >= 0; index--) - { - var saved = restoreActions[index]; - try - { - saved.Restore(); - } - catch (Exception rollbackError) - { - // A broken observer on one plate must not prevent recovery of the others. - rollbackErrors.Add(new InvalidOperationException( - $"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError)); - } - } + var rollbackErrors = RestoreAll(restoreActions); if (rollbackErrors.Count > 0) throw new InvalidOperationException( $"Apply failed: {applyError.Message}\nRestoring cut-offs also failed: " @@ -91,7 +85,27 @@ public static class AutomaticCutOffBatch throw new InvalidOperationException( $"No new cut-offs were retained; original cut-offs were restored. {applyError.Message}", applyError); } - return plans; + return new AutomaticCutOffStaging(plans, restoreActions); + } + + internal static List RestoreAll(IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions) + { + var errors = new List(); + for (var index = restoreActions.Count - 1; index >= 0; index--) + { + var saved = restoreActions[index]; + try + { + saved.Restore(); + } + catch (Exception rollbackError) + { + // A broken observer on one plate must not prevent recovery of the others. + errors.Add(new InvalidOperationException( + $"Plate {saved.PlateNumber}: {rollbackError.Message}", rollbackError)); + } + } + return errors; } private static Action CaptureRestore(Plate plate, AutomaticCutOffPlan plan) @@ -117,3 +131,52 @@ public static class AutomaticCutOffBatch }; } } + +/// +/// Cut-offs added by that are still provisional: either +/// them once the work they belong to is committed, or them. +/// Owner-thread only, like the plates it changed. +/// +public sealed class AutomaticCutOffStaging +{ + private IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions; + + internal AutomaticCutOffStaging(IReadOnlyList plans, + IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions) + { + Plans = plans; + this.restoreActions = restoreActions; + Definitions = plans.SelectMany(plan => plan.HasBlockingDiagnostics ? [] : plan.Definitions).ToArray(); + } + + /// The fresh plans, one per plate in the order given. + public IReadOnlyList Plans { get; } + + /// True when any plate blocked the batch; then nothing was added. + public bool IsBlocked => Plans.Any(plan => plan.HasBlockingDiagnostics); + + /// The definitions this staging added (empty when blocked or nothing was needed). + public IReadOnlyList Definitions { get; } + + /// True while the added cut-offs can still be reverted. + public bool CanRevert => restoreActions.Count > 0; + + /// Makes the added cut-offs permanent; a later does nothing. + public void Keep() => restoreActions = []; + + /// + /// Removes the added cut-offs and restores each touched plate's previous cut-off programs and + /// sequence. Real parts are not touched. Runs once; throws if any plate could not be restored. + /// + public void Revert() + { + var actions = restoreActions; + restoreActions = []; + var errors = AutomaticCutOffBatch.RestoreAll(actions); + if (errors.Count > 0) + throw new InvalidOperationException( + "Removing the added cut-offs failed: " + string.Join("; ", errors.Select(e => e.Message)) + + "\nThe nest may be incomplete; review it before saving or cutting.", + new AggregateException(errors)); + } +} diff --git a/OpenNest.Tests/CutOffs/PlannerCutOffStagingTests.cs b/OpenNest.Tests/CutOffs/PlannerCutOffStagingTests.cs new file mode 100644 index 00000000..8ec5bd84 --- /dev/null +++ b/OpenNest.Tests/CutOffs/PlannerCutOffStagingTests.cs @@ -0,0 +1,190 @@ +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; +using OpenNest.Tests.CuttingPlanning; + +namespace OpenNest.Tests.CutOffs; + +/// The cutting planner's existing-work banner, its Clear button and staged automatic cut-offs. +public class PlannerCutOffStagingTests +{ + private static readonly AutomaticCutOffOptions Options = new() { Spacing = 35 }; + + [Fact] + public void Summarize_CountsAppliedLeadInsLockedPartsAndCutOffs_ButNotStagedOnes() + { + var nest = new Nest(); + var first = Plate(nest, Clean("a", 1, 1), Clean("b", 12, 1), Clean("c", 23, 1)); + var second = Plate(nest, Clean("d", 1, 1)); + var third = Plate(nest, Clean("e", 1, 1)); + Lead(first.Parts[0], locked: true); + Lead(first.Parts[2], locked: false); + var manual = new CutOff(new Vector(60, 0), CutOffAxis.Vertical); + var staged = new CutOff(new Vector(80, 0), CutOffAxis.Vertical); + third.CutOffs.Add(manual); + third.CutOffs.Add(staged); + third.RegenerateCutOffs(new CutOffSettings()); + + var summary = AppliedCuttingWork.Summarize([first, second, third], [4, 5, 6], [staged]); + + Assert.Equal(2, summary.LeadInParts); + Assert.Equal(1, summary.LockedLeadInParts); + Assert.Equal(1, summary.CutOffs); // Cut-off parts are not lead-in parts; the staged line is ignored. + Assert.Equal([4, 6], summary.PlateNumbers); + Assert.True(summary.HasExistingWork); + Assert.Equal("ALREADY APPLIED: lead-ins on 2 parts (1 locked) and 1 cut-off on plates 4, 6." + + " Planning recalculates unlocked lead-ins. Planning keeps locked lead-ins and existing cut-offs unchanged." + + " Clear them to recalculate everything from scratch.", summary.Describe()); + + var clean = AppliedCuttingWork.Summarize([second], null, null); + Assert.False(clean.HasExistingWork); + Assert.Equal(string.Empty, clean.Describe()); + Assert.False(AppliedCuttingWork.Summarize([third], null, third.CutOffs).HasExistingWork); + } + + [Fact] + public void Clear_RemovesLeadInsAndCutOffs_KeepsPosesOrderAndQuantities() + { + var nest = new Nest(); + var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 12, 1), Clean("c", 40, 1)); + var parameters = ExplicitContourTests.Parameters(); + Assert.Equal(CuttingCommitStatus.Applied, + CuttingPlanBatch.Capture([plate], parameters, preservePartOrder: true).Plan().Apply().Status); + plate.Parts[1].LeadInsLocked = true; + plate.CutOffs.Add(new CutOff(new Vector(30, 0), CutOffAxis.Vertical)); + plate.RegenerateCutOffs(new CutOffSettings()); + var real = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff).ToArray(); + var poses = real.Select(p => (p.Location, p.Rotation)).ToArray(); + var quantities = real.Select(p => p.BaseDrawing.Quantity.Nested).ToArray(); + Assert.All(real, p => Assert.True(p.HasManualLeadIns)); + Assert.NotNull(plate.CuttingParameters); + + var cleared = AppliedCuttingWork.Clear([plate], [3]); + + Assert.Equal((3, 1, 1), (cleared.LeadInParts, cleared.LockedLeadInParts, cleared.CutOffs)); + Assert.Equal([3], cleared.PlateNumbers); + Assert.Equal(real, plate.Parts.ToArray()); + Assert.Empty(plate.CutOffs); + Assert.Null(plate.CuttingParameters); + Assert.All(real, p => Assert.False(p.HasManualLeadIns || p.LeadInsLocked)); + Assert.All(real, p => Assert.True(ProgramContent.Equal(p.BaseDrawing.Program, p.Program))); + Assert.Equal(poses, real.Select(p => (p.Location, p.Rotation))); + Assert.Equal(quantities, real.Select(p => p.BaseDrawing.Quantity.Nested)); + Assert.False(AppliedCuttingWork.Summarize([plate]).HasExistingWork); + } + + [Fact] + public void Stage_Revert_RestoresTheEarlierCutOffsAndSequenceExactly() + { + var nest = new Nest(); + var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 40, 1)); + var settings = new CutOffSettings(); + var manual = new CutOff(new Vector(75, 0), CutOffAxis.Vertical); + plate.CutOffs.Add(manual); + plate.RegenerateCutOffs(settings); + var manualPart = plate.Parts[^1]; + plate.Parts.Remove(manualPart); + plate.Parts.Insert(0, manualPart); + var before = plate.Parts.ToArray(); + var manualProgram = manual.Drawing.Program; + + var staging = AutomaticCutOffBatch.Stage([plate], Options, settings); + + Assert.False(staging.IsBlocked); + Assert.True(staging.CanRevert); + Assert.NotEmpty(staging.Definitions); + Assert.Equal(1 + staging.Definitions.Count, plate.CutOffs.Count); + Assert.Equal(plate.CutOffs.Count, plate.Parts.Count(p => p.BaseDrawing.IsCutOff)); + + staging.Revert(); + + Assert.False(staging.CanRevert); + Assert.Same(manual, Assert.Single(plate.CutOffs)); + Assert.Same(manualProgram, manual.Drawing.Program); + Assert.Equal(before, plate.Parts.ToArray()); + staging.Revert(); // Runs once. + Assert.Equal(before, plate.Parts.ToArray()); + } + + [Fact] + public void Stage_Keep_LeavesTheCutOffsInPlace_AndABlockedBatchChangesNothing() + { + var nest = new Nest(); + var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 40, 1)); + var settings = new CutOffSettings(); + var staging = AutomaticCutOffBatch.Stage([plate], Options, settings); + var staged = plate.CutOffs.ToArray(); + + staging.Keep(); + staging.Revert(); + + Assert.False(staging.CanRevert); + Assert.Equal(staged, plate.CutOffs.ToArray()); + Assert.NotEmpty(staged); + + var other = Plate(nest, Clean("c", 1, 1), Clean("d", 40, 1)); + var limited = new CutOff(new Vector(35, 0), CutOffAxis.Vertical) { EndLimit = 2 }; + other.CutOffs.Add(limited); + var parts = other.Parts.ToArray(); + var blocked = AutomaticCutOffBatch.Stage([other], Options, settings); + + Assert.True(blocked.IsBlocked); + Assert.False(blocked.CanRevert); + Assert.Empty(blocked.Definitions); + Assert.Same(limited, Assert.Single(other.CutOffs)); + Assert.Equal(parts, other.Parts.ToArray()); + } + + [Fact] + public void StagedCutOffs_AreSequencedByThePlannerBeforeThePartsTheyCross_AndSurviveApply() + { + var nest = new Nest(); + var plate = Plate(nest, Clean("a", 1, 1), Clean("b", 30, 1), Clean("c", 30, 40), Clean("d", 60, 20)); + var settings = new CutOffSettings(); + var staging = AutomaticCutOffBatch.Stage([plate], Options, settings); + Assert.NotEmpty(staging.Definitions); + + // The planner judges crossings on the bounds it captured, before lead-ins widen them. + var captured = plate.Parts.ToDictionary(p => p, p => p.BoundingBox, ReferenceEqualityComparer.Instance); + var proposal = CuttingPlanBatch.Capture([plate], ExplicitContourTests.Parameters(), preservePartOrder: false).Plan(); + Assert.True(proposal.CanApply); + Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply().Status); + staging.Keep(); + + Assert.Equal(staging.Definitions, plate.CutOffs.ToArray()); + var order = plate.Parts.ToList(); + var bounds = plate.BoundingBox(includeParts: false); + var crossings = 0; + foreach (var definition in plate.CutOffs) + { + var cutIndex = order.FindIndex(p => ReferenceEquals(p.BaseDrawing, definition.Drawing)); + Assert.True(cutIndex >= 0); + foreach (var part in order.Where(p => !p.BaseDrawing.IsCutOff)) + { + Assert.True(part.HasManualLeadIns); + if (!CuttingDependencyGraph.CutOffCrosses(definition.Axis, definition.Position, + definition.StartLimit, definition.EndLimit, captured[part], bounds)) + continue; + crossings++; + Assert.True(cutIndex < order.IndexOf(part)); // Planner rule: a crossing cut-off goes first. + } + } + Assert.True(crossings > 0); // The fixture must exercise cut-off precedence. + } + + private static void Lead(Part part, bool locked) => + Assert.True(part.RestoreLeadInProgram((Program)part.Program.Clone(), locked)); + + 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)); +}