feat(cutoffs): apply nest-wide with minimum retained tail
This commit is contained in:
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user