feat(cutoffs): apply nest-wide with minimum retained tail

This commit is contained in:
aj
2026-09-28 23:43:06 -04:00
parent 4afab63046
commit c3dd346b7a
12 changed files with 735 additions and 92 deletions
@@ -0,0 +1,216 @@
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.IO;
namespace OpenNest.Tests.CutOffs;
public class AutomaticCutOffBatchTests
{
private static readonly AutomaticCutOffOptions Options = new() { Spacing = 35 };
[Fact]
public void Apply_AllPlates_UsesEachSheetAndPreservesRealPartsAndManualSequence()
{
var settings = new CutOffSettings();
var plates = new[] { CreatePlate(1, 36, 120), CreatePlate(3, 50, 100), new Plate(60, 120) };
var realParts = plates.SelectMany(p => p.Parts).ToArray();
var realPrograms = realParts.Select(p => p.Program).ToArray();
var quantities = realParts.Select(p => p.BaseDrawing.Quantity.Nested).ToArray();
var manual = new CutOff(new Vector(5, 0), CutOffAxis.Vertical);
plates[0].CutOffs.Add(manual);
plates[0].RegenerateCutOffs(settings);
var manualPart = plates[0].Parts[^1];
plates[0].Parts.Remove(manualPart);
plates[0].Parts.Insert(0, manualPart);
var preview = AutomaticCutOffBatch.Create(plates, Options, settings);
Assert.Same(manual, Assert.Single(plates[0].CutOffs));
Assert.Empty(plates[1].CutOffs);
Assert.Empty(preview[2].Definitions);
var plans = AutomaticCutOffBatch.Apply(plates, Options, settings);
Assert.All(plans, p => Assert.False(p.HasBlockingDiagnostics));
Assert.Equal(3, plans[0].Definitions.Count);
Assert.Equal(3, plans[1].Definitions.Count);
Assert.Same(manual, plates[0].CutOffs[0]);
Assert.Same(manual.Drawing, plates[0].Parts[0].BaseDrawing);
Assert.Same(realParts[0], plates[0].Parts[1]);
Assert.Same(realParts[1], plates[1].Parts[0]);
Assert.All(plates.Take(2), p => Assert.Equal(p.CutOffs.Count, p.Parts.Count(x => x.BaseDrawing.IsCutOff)));
Assert.All(plates[1].CutOffs, c => Assert.True(c.Position.X < 0));
var tailProgram = plates[1].Parts[^1].Program;
Assert.Equal(50, tailProgram.BoundingBox().Width, 6);
Assert.Empty(plates[2].Parts);
Assert.Empty(plates[2].CutOffs);
Assert.Equal(realPrograms, realParts.Select(p => p.Program));
Assert.Equal(quantities, realParts.Select(p => p.BaseDrawing.Quantity.Nested));
Assert.All(preview.SelectMany(p => p.PreviewParts), p =>
Assert.DoesNotContain(p, plates.SelectMany(plate => plate.Parts)));
var originalParts = plates.Select(p => p.Parts.ToArray()).ToArray();
var repeated = AutomaticCutOffBatch.Apply(plates, Options, settings);
Assert.All(repeated, p => Assert.Empty(p.Definitions));
for (var i = 0; i < plates.Length; i++)
Assert.Equal(originalParts[i], plates[i].Parts.ToArray());
var nest = new Nest("batch-cutoffs");
foreach (var part in realParts)
nest.Drawings.Add(part.BaseDrawing);
nest.Plates.AddRange(plates);
using var stream = new MemoryStream();
Assert.True(new NestWriter(nest).Write(stream));
stream.Position = 0;
var loaded = new NestReader(stream).Read();
// The existing writer omits empty plates.
Assert.Equal(2, loaded.Plates.Count);
for (var i = 0; i < loaded.Plates.Count; i++)
{
Assert.Equal(plates[i].CutOffs.Select(c => (c.Position.X, c.Axis, c.StartLimit, c.EndLimit)),
loaded.Plates[i].CutOffs.Select(c => (c.Position.X, c.Axis, c.StartLimit, c.EndLimit)));
Assert.Equal(plates[i].Parts.Select(p => p.BaseDrawing.IsCutOff),
loaded.Plates[i].Parts.Select(p => p.BaseDrawing.IsCutOff));
}
}
[Fact]
public void Apply_ConflictOnLaterPlate_ChangesNothingOnAnyPlate()
{
var plates = new[] { CreatePlate(), CreatePlate() };
var settings = new CutOffSettings();
var preview = AutomaticCutOffBatch.Create(plates, Options, settings);
Assert.All(preview, p => Assert.NotEmpty(p.Definitions));
var limited = new CutOff(new Vector(35, 0), CutOffAxis.Vertical) { EndLimit = 2 };
plates[1].CutOffs.Add(limited);
var beforeParts = plates.Select(p => p.Parts.ToArray()).ToArray();
var result = AutomaticCutOffBatch.Apply(plates, Options, settings);
Assert.True(result[1].HasBlockingDiagnostics);
Assert.Empty(plates[0].CutOffs);
Assert.Same(limited, Assert.Single(plates[1].CutOffs));
for (var i = 0; i < plates.Length; i++)
Assert.Equal(beforeParts[i], plates[i].Parts.ToArray());
}
[Fact]
public void Apply_InvalidLaterPlate_ReportsPlateNumberBeforeChangingAnything()
{
var plates = new[] { CreatePlate(), CreatePlate() };
plates[1].Parts[0].Offset(500, 0);
var error = Assert.Throws<ArgumentException>(() =>
AutomaticCutOffBatch.Apply(plates, Options, new CutOffSettings()));
Assert.Contains("Plate 2:", error.Message);
Assert.All(plates, p => Assert.Empty(p.CutOffs));
Assert.All(plates, p => Assert.Single(p.Parts));
}
[Fact]
public void Apply_ReplansCurrentPartsAndSettingsInsteadOfAcceptingPreview()
{
var plates = new[] { CreatePlate(), CreatePlate() };
var settings = new CutOffSettings();
var preview = AutomaticCutOffBatch.Create(plates, Options, settings);
plates[1].Parts[0].Offset(5, 0);
settings.PartClearance = 2;
var expected = AutomaticCutOffPlanner.Create(plates[1], Options, settings);
var result = AutomaticCutOffBatch.Apply(plates, Options, settings);
Assert.NotEqual(preview[1].TailSeparatorX, result[1].TailSeparatorX);
Assert.Equal(expected.Definitions.Select(c => c.Position.X), plates[1].CutOffs.Select(c => c.Position.X));
}
[Fact]
public void Apply_RegenerationFailureOnLaterPlate_RestoresEveryTouchedPlate()
{
var plates = new[] { CreatePlate(), CreatePlate(), CreatePlate() };
var settings = new CutOffSettings();
foreach (var plate in plates)
{
plate.CutOffs.Add(new CutOff(new Vector(5, 0), CutOffAxis.Vertical));
plate.RegenerateCutOffs(settings);
var cutoffPart = plate.Parts[^1];
plate.Parts.Remove(cutoffPart);
plate.Parts.Insert(0, cutoffPart);
}
var beforeParts = plates.Select(p => p.Parts.ToArray()).ToArray();
var beforeDefinitions = plates.Select(p => p.CutOffs.ToArray()).ToArray();
var beforePrograms = plates.Select(p => p.CutOffs[0].Drawing.Program).ToArray();
var quantities = plates.Select(p => p.Parts[1].BaseDrawing.Quantity.Nested).ToArray();
var failOnce = true;
plates[1].PartAdded += (_, e) =>
{
if (failOnce && e.Item.BaseDrawing.IsCutOff)
{
failOnce = false;
throw new InvalidOperationException("Injected regeneration failure");
}
};
var error = Assert.Throws<InvalidOperationException>(() =>
AutomaticCutOffBatch.Apply(plates, Options, settings));
Assert.False(failOnce);
Assert.Contains("original cut-offs were restored", error.Message);
for (var i = 0; i < plates.Length; i++)
{
Assert.Equal(beforeParts[i], plates[i].Parts.ToArray());
Assert.Equal(beforeDefinitions[i], plates[i].CutOffs.ToArray());
Assert.Same(beforePrograms[i], plates[i].CutOffs[0].Drawing.Program);
Assert.Equal(quantities[i], plates[i].Parts[1].BaseDrawing.Quantity.Nested);
}
}
[Fact]
public void Apply_MinimumTailIsEvaluatedIndependentlyForEachPlate()
{
var plates = new[] { CreatePlate(length: 90), CreatePlate(), new Plate(36, 90) };
var options = new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 12 };
var result = AutomaticCutOffBatch.Apply(plates, options, new CutOffSettings());
Assert.False(result[0].HasSeparatedTail);
Assert.Equal(new[] { 35.0, 70.0 }, plates[0].CutOffs.Select(c => c.Position.X));
Assert.True(result[1].HasSeparatedTail);
Assert.Equal(3, plates[1].CutOffs.Count);
Assert.Empty(plates[2].CutOffs);
Assert.Empty(AutomaticCutOffBatch.Apply(plates, options, new CutOffSettings()).SelectMany(p => p.Definitions));
}
[Fact]
public void Apply_RollbackFailureIsExplicitAndDoesNotStopRecoveryOfOtherPlates()
{
var plates = new[] { CreatePlate(), CreatePlate() };
var originals = plates.Select(p => p.Parts.ToArray()).ToArray();
plates[1].PartAdded += (_, _) => throw new InvalidOperationException("Apply observer failed");
plates[1].CutOffs.ItemRemoved += (_, _) => throw new InvalidOperationException("Rollback observer failed");
var error = Assert.Throws<InvalidOperationException>(() =>
AutomaticCutOffBatch.Apply(plates, Options, new CutOffSettings()));
Assert.Contains("Restoring cut-offs also failed", error.Message);
Assert.Contains("Plate 2", error.Message);
Assert.Contains("may be incomplete", error.Message);
Assert.IsType<AggregateException>(error.InnerException);
Assert.Empty(plates[0].CutOffs);
Assert.Equal(originals[0], plates[0].Parts.ToArray());
}
private static Plate CreatePlate(int quadrant = 1, double width = 36, double length = 120)
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(70, 0));
program.Codes.Add(new LinearMove(70, 20));
program.Codes.Add(new LinearMove(0, 20));
program.Codes.Add(new LinearMove(0, 0));
var plate = new Plate(width, length) { Quadrant = quadrant, PartSpacing = 0.5, Quantity = 2 };
var drawing = new Drawing($"rectangle-q{quadrant}", program);
var location = new Vector(quadrant is 2 or 3 ? -80 : 10, quadrant is 3 or 4 ? -28 : 8);
plate.Parts.Add(new Part(drawing, location));
return plate;
}
}
@@ -68,6 +68,93 @@ public class AutomaticCutOffPlannerTests
Assert.Equal(new Vector(separator, 81), segment.To);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MinimumTail_SkipsOnlyFinalSeparatorWithoutExtendingSkeletonGrid(int quadrant)
{
// The proposed separator is just before X=105, with a tail shorter than 16.
// Skipping it must not add the otherwise-next nominal line at X=105.
var plate = MakePlate(quadrant, occupied: 104);
var before = Plan(plate);
var options = new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 16 };
var plan = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings());
Assert.Equal(before.Definitions.Take(before.Definitions.Count - 1).Select(c => c.Position.X), Positions(plan));
Assert.False(plan.HasSeparatedTail);
Assert.Null(plan.TailSeparatorX);
Assert.Equal(0, plan.TailLength);
Assert.Equal(120, plan.UsedSpan);
Assert.Contains(plan.Diagnostics, d => d.Message.Contains("minimum tail length"));
Assert.Empty(plate.CutOffs);
Assert.Single(plate.Parts);
}
[Theory]
[InlineData(1, -0.01, false)]
[InlineData(1, 0, true)]
[InlineData(1, 0.01, true)]
[InlineData(2, -0.01, false)]
[InlineData(2, 0, true)]
[InlineData(2, 0.01, true)]
[InlineData(3, -0.01, false)]
[InlineData(3, 0, true)]
[InlineData(3, 0.01, true)]
[InlineData(4, -0.01, false)]
[InlineData(4, 0, true)]
[InlineData(4, 0.01, true)]
public void MinimumTail_TwelveInchesIsInclusiveAfterSeparatorClearance(int quadrant, double extra, bool keep)
{
var occupied = 120 - 12 - 0.5 - Tolerance.Epsilon - extra;
var plate = MakePlate(quadrant, occupied);
var options = new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 12 };
var plan = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings());
Assert.Equal(keep, plan.HasSeparatedTail);
Assert.Equal(keep ? 4 : 3, plan.Definitions.Count);
if (keep)
Assert.Equal(12 + extra, plan.TailLength, 6);
}
[Fact]
public void MinimumTail_DoesNotRemoveExistingSeparatorWhenRerunWithHigherMinimum()
{
var plate = MakePlate(occupied: 110);
var settings = new CutOffSettings();
var original = Plan(plate);
plate.CutOffs.AddRange(original.Definitions);
plate.RegenerateCutOffs(settings);
var definitions = plate.CutOffs.ToArray();
var parts = plate.Parts.ToArray();
var programs = definitions.Select(c => c.Drawing.Program).ToArray();
var plan = AutomaticCutOffPlanner.Create(plate,
new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 12 }, settings);
Assert.Empty(plan.Definitions);
Assert.False(plan.HasSeparatedTail);
Assert.Equal(definitions, plate.CutOffs.ToArray());
Assert.Equal(parts, plate.Parts.ToArray());
Assert.Equal(programs, plate.CutOffs.Select(c => c.Drawing.Program));
}
[Theory]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void MinimumTail_InvalidValueIsRejected(double minimum)
{
var plate = MakePlate();
Assert.Throws<ArgumentException>(() => AutomaticCutOffPlanner.Create(plate,
new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = minimum }, new CutOffSettings()));
Assert.Empty(plate.CutOffs);
}
[Theory]
[InlineData(2, 0.5)]
[InlineData(0.5, 2)]