mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-25 23:21:07 -04:00
feat(cutoffs): stage revertible automatic cut-offs and clear applied cutting work
This commit is contained in:
1 parent
c107ab13f7
commit
81cccfc610
3 files changed
+363
-17
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user