feat(cutoffs): stage revertible automatic cut-offs and clear applied cutting work

This commit is contained in:
aj committed 2026-10-13 20:39:43 -04:00
1 parent c107ab13f7
commit 81cccfc610
3 files changed
+363 -17

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace OpenNest;
/// <summary>Existing cutting work on a set of plates: applied lead-ins and cut-offs.</summary>
/// <param name="LeadInParts">Real parts carrying applied lead-ins (locked ones included).</param>
/// <param name="LockedLeadInParts">Of those, parts whose lead-ins are locked.</param>
/// <param name="CutOffs">Cut-off definitions.</param>
/// <param name="PlateNumbers">Display numbers of the plates holding any of it, ascending.</param>
public sealed record AppliedCuttingWorkSummary(int LeadInParts, int LockedLeadInParts, int CutOffs,
IReadOnlyList<int> PlateNumbers)
{
public bool HasExistingWork => LeadInParts > 0 || CutOffs > 0;
/// <summary>Operator text for the cutting planner's warning banner; empty when nothing exists.</summary>
public string Describe()
{
if (!HasExistingWork)
return string.Empty;
var found = new List<string>();
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<string>();
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")}";
}
/// <summary>Finds and clears applied lead-ins and cut-offs so a plan can recalculate them.</summary>
public static class AppliedCuttingWork
{
/// <summary>
/// Counts applied lead-ins on real parts and cut-off definitions. Definitions in
/// <paramref name="ignoredCutOffs"/> (for example ones staged by the caller) are not counted.
/// </summary>
public static AppliedCuttingWorkSummary Summarize(IReadOnlyList<Plate> plates,
IReadOnlyList<int> plateNumbers = null, IEnumerable<CutOff> 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<CutOff>(ignoredCutOffs ?? [], ReferenceEqualityComparer.Instance);
int leads = 0, locked = 0, cutOffs = 0;
var numbers = new SortedSet<int>();
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());
}
/// <summary>
/// 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.
/// </summary>
public static AppliedCuttingWorkSummary Clear(IReadOnlyList<Plate> plates, IReadOnlyList<int> 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;
}
}
+80 -17
View File
@@ -42,11 +42,19 @@ public static class AutomaticCutOffBatch
/// Returned plans describe the fresh proposal, not preview parts to accept into a plate.
/// </summary>
public static IReadOnlyList<AutomaticCutOffPlan> Apply(IReadOnlyList<Plate> plates,
AutomaticCutOffOptions options, CutOffSettings settings) => Stage(plates, options, settings).Plans;
/// <summary>
/// Applies like <see cref="Apply"/>, but keeps the recovery state so the caller can take the
/// new cut-offs back off again (<see cref="AutomaticCutOffStaging.Revert"/>) when the work they
/// were added for is abandoned. A blocked batch changes nothing and has nothing to revert.
/// </summary>
public static AutomaticCutOffStaging Stage(IReadOnlyList<Plate> 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<Exception>();
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<Exception> RestoreAll(IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions)
{
var errors = new List<Exception>();
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
};
}
}
/// <summary>
/// Cut-offs added by <see cref="AutomaticCutOffBatch.Stage"/> that are still provisional: either
/// <see cref="Keep"/> them once the work they belong to is committed, or <see cref="Revert"/> them.
/// Owner-thread only, like the plates it changed.
/// </summary>
public sealed class AutomaticCutOffStaging
{
private IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions;
internal AutomaticCutOffStaging(IReadOnlyList<AutomaticCutOffPlan> plans,
IReadOnlyList<(int PlateNumber, Action Restore)> restoreActions)
{
Plans = plans;
this.restoreActions = restoreActions;
Definitions = plans.SelectMany(plan => plan.HasBlockingDiagnostics ? [] : plan.Definitions).ToArray();
}
/// <summary>The fresh plans, one per plate in the order given.</summary>
public IReadOnlyList<AutomaticCutOffPlan> Plans { get; }
/// <summary>True when any plate blocked the batch; then nothing was added.</summary>
public bool IsBlocked => Plans.Any(plan => plan.HasBlockingDiagnostics);
/// <summary>The definitions this staging added (empty when blocked or nothing was needed).</summary>
public IReadOnlyList<CutOff> Definitions { get; }
/// <summary>True while the added cut-offs can still be reverted.</summary>
public bool CanRevert => restoreActions.Count > 0;
/// <summary>Makes the added cut-offs permanent; a later <see cref="Revert"/> does nothing.</summary>
public void Keep() => restoreActions = [];
/// <summary>
/// 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.
/// </summary>
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));
}
}
@@ -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;
/// <summary>The cutting planner's existing-work banner, its Clear button and staged automatic cut-offs.</summary>
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));
}