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