Files
OpenNest/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs
T
2026-11-12 19:38:20 -05:00

1055 lines
46 KiB
C#

using System.Reflection;
using OpenNest.Clamps;
using OpenNest.CNC;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Math;
namespace OpenNest.Tests.CutOffs;
public class AutomaticCutOffPlannerTests
{
private static Plate MakePlate(int quadrant = 1, double occupied = 80)
{
var plate = new Plate(81, 120) { Quadrant = quadrant, PartSpacing = 0.5, Quantity = 2 };
var x = quadrant is 2 or 3 ? -occupied : 10;
var y = quadrant is 3 or 4 ? -40 : 20;
plate.Parts.Add(Rectangle(occupied - 10, 20, new Vector(x, y)));
return plate;
}
private static Part Rectangle(double length, double width, Vector location)
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(length, 0));
program.Codes.Add(new LinearMove(length, width));
program.Codes.Add(new LinearMove(0, width));
program.Codes.Add(new LinearMove(0, 0));
var drawing = new Drawing("rectangle", program);
Assert.Equal(length * width, drawing.Area, 8);
return new Part(drawing, location);
}
private static AutomaticCutOffPlan Plan(Plate plate, double spacing = 35,
CutOffSettings? settings = null) => AutomaticCutOffPlanner.Create(
plate, new AutomaticCutOffOptions { Spacing = spacing }, settings ?? new CutOffSettings());
private static double[] Positions(AutomaticCutOffPlan plan) =>
plan.Definitions.Select(c => c.Position.X).ToArray();
private static void AssertPositions(double[] expected, AutomaticCutOffPlan plan)
{
var actual = Positions(plan);
Assert.Equal(expected.Length, actual.Length);
for (var index = 0; index < expected.Length; index++)
Assert.Equal(expected[index], actual[index], 9);
}
private static (Vector From, Vector To)[] Segments(Program program) =>
Enumerable.Range(0, program.Codes.Count / 2).Select(i => (
Assert.IsType<RapidMove>(program.Codes[2 * i]).EndPoint,
Assert.IsType<LinearMove>(program.Codes[2 * i + 1]).EndPoint)).ToArray();
[Fact]
public void UsedEnvelope_StopsBeforeUnusedTail_AndPreviewsAreDetached()
{
var plate = MakePlate();
var plan = Plan(plate);
var separator = 80 + 0.5 + Tolerance.Epsilon;
Assert.Equal(new[] { 35, 70, separator }, Positions(plan));
Assert.All(plan.Definitions, c => Assert.Equal(CutOffAxis.Vertical, c.Axis));
Assert.Equal(80, plan.OccupiedSpan);
Assert.Equal(separator, plan.UsedSpan);
Assert.Equal(120 - separator, plan.TailLength);
Assert.Equal(separator, plan.TailSeparatorX);
Assert.True(plan.HasSeparatedTail);
Assert.False(plan.HasBlockingDiagnostics);
Assert.Equal(plan.Definitions.Count, plan.PreviewParts.Count);
for (var i = 0; i < plan.Definitions.Count; i++)
{
Assert.Same(plan.Definitions[i].Drawing, plan.PreviewParts[i].BaseDrawing);
Assert.DoesNotContain(plan.PreviewParts[i], plate.Parts);
}
var segment = Assert.Single(Segments(plan.Definitions[^1].Drawing.Program));
Assert.Equal(new Vector(separator, 0), segment.From);
Assert.Equal(new Vector(separator, 81), segment.To);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void MinimumTail_SkippedSeparatorExtendsSkeletonGridToSheetEnd(int quadrant)
{
// The proposed separator is just before X=105, with a tail shorter than 16.
// Skipping it leaves the tail on the skeleton, so the grid must continue with
// the line at X=105; otherwise the final strip would run 70 to 120.
var plate = MakePlate(quadrant, occupied: 104);
var sign = quadrant is 2 or 3 ? -1 : 1;
var options = new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 16 };
var plan = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings());
Assert.Equal(new[] { 35.0, 70, 105 }.Select(x => sign * 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, null)]
[InlineData(1, 17.5)]
[InlineData(2, 17.5)]
[InlineData(3, 17.5)]
[InlineData(4, 17.5)]
public void MinimumTail_SkippedSeparatorKeepsEveryStripWithinSpacing(int quadrant, double? minimumStrip)
{
// A 144-long sheet used through 139.75 proposes a separator at 140 with a 4-long
// tail. With the separator skipped, no strip may exceed the 35 spacing.
var plate = new Plate(60, 144) { Quadrant = quadrant, PartSpacing = 0.25 };
var sign = quadrant is 2 or 3 ? -1 : 1;
plate.Parts.Add(Rectangle(138, 8, new Vector(quadrant is 2 or 3 ? -139.75 : 1.75,
quadrant is 3 or 4 ? -10 : 2)));
var options = new AutomaticCutOffOptions
{
Spacing = 35,
MinimumStripLength = minimumStrip,
MinimumTailLength = 12,
};
var plan = AutomaticCutOffPlanner.Create(plate, options, new CutOffSettings());
Assert.False(plan.HasSeparatedTail);
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.TailBelowMinimum);
var boundaries = Positions(plan).Select(x => sign * x).Prepend(0).Append(144).OrderBy(x => x).ToArray();
Assert.Equal(6, boundaries.Length);
Assert.All(boundaries.Zip(boundaries.Skip(1), (a, b) => b - a),
strip => Assert.InRange(strip, Tolerance.Epsilon, 35 + Tolerance.Epsilon));
}
[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)]
[InlineData(2, 2)]
[InlineData(0, 0)]
public void TailUsesGreaterClearance_NotSum(double partSpacing, double clearance)
{
var plate = MakePlate();
plate.PartSpacing = partSpacing;
var plan = Plan(plate, settings: new CutOffSettings { PartClearance = clearance });
Assert.Equal(80 + System.Math.Max(partSpacing, clearance) + Tolerance.Epsilon,
plan.TailSeparatorX);
}
[Theory]
[InlineData(1, 1)]
[InlineData(2, -1)]
[InlineData(3, -1)]
[InlineData(4, 1)]
public void AllQuadrants_AdvanceXFromOrigin_AcrossPhysicalWidth(int quadrant, int sign)
{
var plate = MakePlate(quadrant);
plate.EdgeSpacing = new Spacing(10, 10);
var settings = new CutOffSettings { Overtravel = 3 };
var plan = Plan(plate, settings: settings);
Assert.Equal(new[] { sign * 35.0, sign * 70.0, sign * (80.5 + Tolerance.Epsilon) },
Positions(plan));
var bounds = plate.BoundingBox(false);
var segment = Assert.Single(Segments(plan.Definitions[^1].Drawing.Program));
Assert.Equal(bounds.Bottom, segment.From.Y);
Assert.Equal(bounds.Top + 3, segment.To.Y);
Assert.Equal(80.5 + Tolerance.Epsilon, plan.UsedSpan);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void ClampWall_SkipsNominalLineAndMovesTailSeparatorPastClamp(int quadrant)
{
var plate = MakePlate(quadrant);
var sign = quadrant is 2 or 3 ? -1 : 1;
plate.EdgeSpacing = new Spacing(0.25, 0.25);
// Real rail zones are stored in the sheet's lower-left frame in every quadrant.
// The tail clamp covers 80.6..80.8; with 0.25 clearance the separator moves to 81.05.
plate.SetClampRail(new ClampRail(0.2, 0.625, new[] { 35.2, 80.7 }));
var before = plate.Parts.ToArray();
var plan = Plan(plate);
var separator = 81.05 + Tolerance.Epsilon;
AssertPositions(new[] { sign * 70.0, sign * separator }, plan);
Assert.True(plan.HasSeparatedTail);
Assert.Equal(120 - separator, plan.TailLength, 9);
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampMovedTailSeparator);
Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
Assert.Equal(before, plate.Parts.ToArray());
Assert.Empty(plate.CutOffs);
}
[Fact]
public void ClampOnlyBlocksItsInflatedSpan_AndRemovedClampStopsBlocking()
{
var plate = MakePlate();
plate.EdgeSpacing = new Spacing(0.25, 0.25);
plate.ClampZones.Add(new ClampZone("front", new Box(35.25, 0, 1.5, 0.625)));
var blocked = Plan(plate);
Assert.Equal(new[] { 70, 80.5 + Tolerance.Epsilon }, Positions(blocked));
Assert.True(blocked.HasSeparatedTail);
plate.ClampZones.Clear();
Assert.Equal(new[] { 35, 70, 80.5 + Tolerance.Epsilon }, Positions(Plan(plate)));
}
[Fact]
public void ExistingLineAcrossClampCannotCertifyTail_SeparatorMovesPastClamp()
{
var plate = MakePlate();
plate.EdgeSpacing = new Spacing(0.25, 0.25);
plate.ClampZones.Add(new ClampZone("hanging", new Box(80.6, 0, 0.2, 0.625)));
var existing = new CutOff(new Vector(80.5 + Tolerance.Epsilon, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(existing);
existing.Regenerate(plate, new CutOffSettings());
var original = existing.Drawing.Program;
var plan = Plan(plate);
// The existing line is not certified; a new separator past the clamp keeps the tail.
Assert.Equal(81.05 + Tolerance.Epsilon, plan.TailSeparatorX!.Value, 9);
Assert.Equal(plan.TailSeparatorX, plan.Definitions[^1].Position.X);
Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff);
Assert.Same(original, existing.Drawing.Program);
Assert.Same(existing, Assert.Single(plate.CutOffs));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void NearEquivalentExistingSeparatorInsideClampCannotCertifyTail(int quadrant)
{
// The nominal separator (80.50001) lies just beyond the clamp edge (80.5000075),
// but the equivalent existing line that would be certified (80.500005) is inside it.
var plate = MakePlate(quadrant);
var sign = quadrant is 2 or 3 ? -1 : 1;
// Footprint 79.500005..80.5000075 from the origin along the edge, in every quadrant.
plate.SetClampRail(new ClampRail(1.0000025, 0.625, new[] { 80.00000625 }));
var existing = new CutOff(new Vector(sign * 80.500005, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(existing);
existing.Regenerate(plate, new CutOffSettings());
var original = existing.Drawing.Program;
var plan = Plan(plate);
Assert.False(plan.HasSeparatedTail);
Assert.Null(plan.TailSeparatorX);
Assert.Equal(0, plan.TailLength);
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut);
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff);
Assert.Equal(new[] { sign * 35.0, sign * 70.0 }, Positions(plan));
Assert.Same(original, existing.Drawing.Program);
Assert.Same(existing, Assert.Single(plate.CutOffs));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void RailClampsBlockGridAndMoveTailInEveryQuadrant(int quadrant)
{
// Rail clamps centered 35 and 80.5 from the origin, 1.5 wide, plus 0.25 clearance:
// the grid line at 35 is skipped and the separator at 80.50001 moves past the
// clamp's clearance (79.75..81.25 + 0.25) to 81.50001.
var plate = MakePlate(quadrant);
var sign = quadrant is 2 or 3 ? -1 : 1;
plate.EdgeSpacing = new Spacing(0.25, 0.25);
plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 35.0, 80.5 }));
var plan = Plan(plate);
AssertPositions(new[] { sign * 70.0, sign * (81.5 + Tolerance.Epsilon) }, plan);
Assert.True(plan.HasSeparatedTail);
Assert.Single(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut);
var moved = Assert.Single(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampMovedTailSeparator);
Assert.Equal(plan.TailSeparatorX, moved.X);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void TailSeparatorStepsPastAdjacentClamps(int quadrant)
{
// Moving past the first clamp (79.75..81.25) lands in the second one's clearance
// (81.6..83.1 less 0.25), so the separator steps past both to 83.35001.
var plate = MakePlate(quadrant);
var sign = quadrant is 2 or 3 ? -1 : 1;
plate.EdgeSpacing = new Spacing(0.25, 0.25);
plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 80.5, 82.35 }));
var plan = Plan(plate);
Assert.Equal(sign * (83.35 + Tolerance.Epsilon), plan.TailSeparatorX!.Value, 9);
}
[Fact]
public void ClampMovedTailBelowMinimumKeepsNoTail()
{
// Past the clamp the tail is 120 - 81.50001 = 38.49999, under a 38.6 minimum.
var plate = MakePlate();
plate.EdgeSpacing = new Spacing(0.25, 0.25);
plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 80.5 }));
var plan = AutomaticCutOffPlanner.Create(plate,
new AutomaticCutOffOptions { Spacing = 35, MinimumTailLength = 38.6 }, new CutOffSettings());
Assert.False(plan.HasSeparatedTail);
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.TailBelowMinimum);
Assert.DoesNotContain(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampMovedTailSeparator);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void RailClampClearanceOnlyContactBlocksAndClearLineSurvives(int quadrant)
{
// Physical footprint 36..37.5; the 0.25 clearance reaches a line at 35.75 but not 35.
var plate = MakePlate(quadrant);
var sign = quadrant is 2 or 3 ? -1 : 1;
plate.EdgeSpacing = new Spacing(0.25, 0.25);
plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 36.75 }));
Assert.Equal(new[] { sign * 35.0, sign * 70.0, sign * (80.5 + Tolerance.Epsilon) },
Positions(Plan(plate)));
Assert.Equal(new[] { sign * 71.5, sign * (80.5 + Tolerance.Epsilon) },
Positions(Plan(plate, spacing: 35.75)));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void HangingRailClampKeepsWholeFootprintForTail(int quadrant)
{
// Clamp centered 120.5 on a 120-long sheet covers 119.75..121.25 from the origin;
// with 0.25 clearance it blocks the separator at 119.50001 behind a 119-long envelope.
var plate = MakePlate(quadrant, occupied: 119);
plate.EdgeSpacing = new Spacing(0.25, 0.25);
plate.SetClampRail(new ClampRail(1.5, 0.625, new[] { 120.5 }));
var plan = Plan(plate, spacing: 119.6);
Assert.False(plan.HasSeparatedTail);
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut);
Assert.Empty(plan.Definitions);
}
[Fact]
public void SameLineLimitedManualCutOnClamp_StillBlocksWholePlan()
{
const double x = 35;
var plate = MakePlate();
plate.ClampZones.Add(new ClampZone("under-manual", new Box(x - 0.75, 0, 1.5, 0.625)));
var manual = new CutOff(new Vector(x, 0), CutOffAxis.Vertical) { StartLimit = 10, EndLimit = 60 };
plate.CutOffs.Add(manual);
var program = manual.Drawing.Program;
var plan = Plan(plate);
Assert.True(plan.HasBlockingDiagnostics);
Assert.Empty(plan.Definitions);
Assert.Empty(plan.PreviewParts);
Assert.False(plan.HasSeparatedTail);
Assert.Contains(plan.Diagnostics, d => d.IsBlocking &&
d.Code == AutomaticCutOffDiagnosticCode.LimitedCutOffConflict);
Assert.Same(program, manual.Drawing.Program);
Assert.Same(manual, Assert.Single(plate.CutOffs));
}
[Fact]
public void Planning_DoesNotMutateAnyLiveObjectsOrSequence()
{
var plate = MakePlate();
var drawing = plate.Parts[0].BaseDrawing;
var nest = new Nest("test");
nest.Drawings.Add(drawing);
nest.Plates.Add(plate);
var manual = new CutOff(new Vector(25, 0), CutOffAxis.Vertical)
{ StartLimit = 5, EndLimit = 12 };
plate.CutOffs.Add(manual);
plate.RegenerateCutOffs(new CutOffSettings());
var cutoffPart = plate.Parts[^1];
plate.Parts.Remove(cutoffPart);
plate.Parts.Insert(0, cutoffPart);
var parts = plate.Parts.ToArray();
var definitions = plate.CutOffs.ToArray();
var programs = parts.Select(p => p.Program).ToArray();
var text = programs.Select(p => p.ToString()).ToArray();
var locations = parts.Select(p => p.Location).ToArray();
var bounds = parts.Select(p => p.BoundingBox).ToArray();
var drawingProgram = drawing.Program;
var cutoffProgram = manual.Drawing.Program;
var nested = drawing.Quantity.Nested;
var changes = 0;
plate.PartAdded += (_, _) => changes++;
plate.PartRemoved += (_, _) => changes++;
plate.PartChanged += (_, _) => changes++;
var plan = Plan(plate);
Assert.NotEmpty(plan.Definitions);
Assert.Equal(parts, plate.Parts.ToArray());
Assert.Equal(definitions, plate.CutOffs.ToArray());
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
Assert.Equal(text, plate.Parts.Select(p => p.Program.ToString()));
Assert.Equal(locations, plate.Parts.Select(p => p.Location));
Assert.Equal(bounds, plate.Parts.Select(p => p.BoundingBox));
Assert.Same(drawingProgram, drawing.Program);
Assert.Same(cutoffProgram, manual.Drawing.Program);
Assert.Equal(nested, drawing.Quantity.Nested);
Assert.Same(drawing, Assert.Single(nest.Drawings));
Assert.Equal(5, manual.StartLimit);
Assert.Equal(12, manual.EndLimit);
Assert.Equal(0, changes);
}
[Theory]
[InlineData(CutDirection.AwayFromOrigin)]
[InlineData(CutDirection.TowardOrigin)]
public void RepeatedLines_UseCurrentManualSegmentation_WithoutIncreasingClearance(CutDirection direction)
{
var plate = MakePlate();
plate.PartSpacing = 8;
var settings = new CutOffSettings
{ PartClearance = 1, Overtravel = 2, MinSegmentLength = 0.5, CutDirection = direction };
var plan = Plan(plate, settings: settings);
var automatic = plan.Definitions[0];
var manual = new CutOff(automatic.Position, automatic.Axis);
manual.Regenerate(plate, settings, Plate.BuildPerimeterCache(plate));
Assert.Equal(manual.Drawing.Program.ToString(), automatic.Drawing.Program.ToString());
var segments = Segments(automatic.Drawing.Program);
Assert.Equal(2, segments.Length);
Assert.All(segments, s => Assert.True(
System.Math.Max(s.From.Y, s.To.Y) <= 19 ||
System.Math.Min(s.From.Y, s.To.Y) >= 41));
Assert.Contains(segments, s => System.Math.Abs(s.To.Y - 19) < 0.01 ||
System.Math.Abs(s.From.Y - 19) < 0.01);
Assert.Equal(1, settings.PartClearance);
}
[Fact]
public void EmptyPlate_AndCutOffOnlyPlate_AreNeverChopped()
{
var plate = new Plate(81, 120);
Assert.Empty(Plan(plate).Definitions);
var old = new CutOff(new Vector(110, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(old);
plate.RegenerateCutOffs(new CutOffSettings());
var plan = Plan(plate);
Assert.Empty(plan.Definitions);
Assert.Empty(plan.PreviewParts);
Assert.Equal(0, plan.OccupiedSpan);
Assert.Equal(0, plan.UsedSpan);
Assert.Equal(0, plan.TailLength);
Assert.False(plan.HasSeparatedTail);
}
[Fact]
public void OldCutOffParts_DoNotExtendTheEnvelope()
{
var plate = MakePlate();
plate.CutOffs.Add(new CutOff(new Vector(115, 0), CutOffAxis.Vertical));
plate.RegenerateCutOffs(new CutOffSettings());
Assert.Equal(new[] { 35, 70, 80.5 + Tolerance.Epsilon }, Positions(Plan(plate)));
}
[Fact]
public void UsedSpanSmallerThanPitch_OnlyGeneratesSeparator()
{
var plan = Plan(MakePlate(occupied: 20));
Assert.Equal(new[] { 20.5 + Tolerance.Epsilon }, Positions(plan));
}
[Fact]
public void ExactPitchAtSeparator_DoesNotGenerateBoundaryTwice()
{
var plate = MakePlate(occupied: 69.5 - Tolerance.Epsilon);
Assert.Equal(new[] { 35.0, 70.0 }, Positions(Plan(plate)));
}
[Theory]
[InlineData(119.5)]
[InlineData(120)]
public void NoRoomForSeparator_UsesFullSheet_NoEdgeDuplicate(double occupied)
{
var plan = Plan(MakePlate(occupied: occupied), spacing: 40);
Assert.Equal(new[] { 40.0, 80.0 }, Positions(plan));
Assert.Equal(120, plan.UsedSpan);
Assert.False(plan.HasSeparatedTail);
Assert.Null(plan.TailSeparatorX);
Assert.Equal(0, plan.TailLength);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
[InlineData(double.Epsilon)]
[InlineData(0.000001)]
[InlineData(0.00001)]
[InlineData(0.001)]
public void InvalidOrExcessivePitch_IsRejected(double pitch)
{
Assert.ThrowsAny<ArgumentException>(() => Plan(MakePlate(), pitch));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void InvalidSheetDimensions_AreRejected(double value)
{
var plate = MakePlate();
plate.Size = new Size(value, 120);
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
plate.Size = new Size(81, value);
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
}
[Theory]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
public void InvalidPartSpacingAndSettings_AreRejected(double value)
{
var plate = MakePlate();
plate.PartSpacing = value;
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
plate.PartSpacing = 0;
Assert.ThrowsAny<ArgumentException>(() => Plan(plate,
settings: new CutOffSettings { PartClearance = value }));
Assert.ThrowsAny<ArgumentException>(() => Plan(plate,
settings: new CutOffSettings { Overtravel = value }));
Assert.ThrowsAny<ArgumentException>(() => Plan(plate,
settings: new CutOffSettings { MinSegmentLength = value }));
}
[Fact]
public void InvalidQuadrantOrDirection_IsRejected()
{
var plate = MakePlate();
Assert.ThrowsAny<ArgumentException>(() => Plan(plate,
settings: new CutOffSettings { CutDirection = (CutDirection)42 }));
// The public setter coerces bad quadrants; simulate damaged state explicitly.
typeof(Plate).GetField("quadrant", BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(plate, 0);
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
}
[Fact]
public void NullArguments_AreRejected()
{
Assert.Throws<ArgumentNullException>(() => AutomaticCutOffPlanner.Create(null!, new(), new()));
Assert.Throws<ArgumentNullException>(() => AutomaticCutOffPlanner.Create(MakePlate(), null!, new()));
Assert.Throws<ArgumentNullException>(() => AutomaticCutOffPlanner.Create(MakePlate(), new(), null!));
}
[Theory]
[InlineData(-11, 0)]
[InlineData(50, 0)]
[InlineData(0, -21)]
[InlineData(0, 42)]
[InlineData(double.NaN, 0)]
[InlineData(double.PositiveInfinity, 0)]
public void InvalidOrOutOfSheetGeometry_IsRejected(double dx, double dy)
{
var plate = MakePlate();
plate.Parts[0].Offset(dx, dy);
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
}
[Fact]
public void EmptyOrNonfiniteProgramGeometry_IsRejected_EvenWithOldBounds()
{
var plate = MakePlate();
plate.Parts[0].Program.Codes.Clear();
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
plate = MakePlate();
((Motion)plate.Parts[0].Program.Codes[2]).EndPoint = new Vector(double.NaN, 2);
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
}
[Fact]
public void EmptyRepeatedCuts_AreDiagnostic_NotAcceptedPartitions()
{
var plate = new Plate(81, 120);
plate.Parts.Add(Rectangle(80, 81, new Vector()));
var plan = Plan(plate);
Assert.Single(plan.Definitions);
Assert.True(plan.HasSeparatedTail);
Assert.Equal(2, plan.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.EmptyCut));
}
[Fact]
public void FilteredSeparator_DoesNotClaimSeparatedTail()
{
var plan = Plan(MakePlate(), settings: new CutOffSettings { MinSegmentLength = 100 });
Assert.Empty(plan.Definitions);
Assert.False(plan.HasSeparatedTail);
Assert.Equal(0, plan.TailLength);
Assert.Null(plan.TailSeparatorX);
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
}
[Fact]
public void ApplyingDefinitions_ThenRerunning_PreservesPartsProgramsQuantitiesAndSequence()
{
var plate = MakePlate();
var real = plate.Parts[0];
var secondReal = new Part(real.BaseDrawing, new Vector(0, 60));
plate.Parts.Add(secondReal);
var secondProgram = secondReal.Program;
var secondLocation = secondReal.Location;
var program = real.Program;
var text = program.ToString();
var location = real.Location;
var nested = real.BaseDrawing.Quantity.Nested;
var settings = new CutOffSettings { Overtravel = 2, PartClearance = 0.75 };
var manual = new CutOff(new Vector(15, 0), CutOffAxis.Horizontal)
{ StartLimit = 5, EndLimit = 15 };
plate.CutOffs.Add(manual);
plate.RegenerateCutOffs(settings);
var manualPart = plate.Parts[^1];
plate.Parts.Remove(manualPart);
plate.Parts.Insert(0, manualPart);
var first = Plan(plate, settings: settings);
foreach (var definition in first.Definitions)
plate.CutOffs.Add(definition);
plate.RegenerateCutOffs(settings);
var second = Plan(plate, settings: settings);
Assert.Empty(second.Definitions);
Assert.True(second.HasSeparatedTail);
Assert.Equal(first.UsedSpan, second.UsedSpan);
Assert.Equal(3, second.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff));
plate.RegenerateCutOffs(settings);
Assert.Equal(TestHelpers.CutOffPartCount(plate), plate.Parts.Count(p => p.BaseDrawing.IsCutOff));
Assert.All(plate.CutOffs, c => Assert.Equal(c.SegmentPrograms().Count, TestHelpers.CutOffParts(plate, c).Length));
Assert.Same(manual.Drawing, plate.Parts[0].BaseDrawing);
Assert.Same(real, plate.Parts[1]);
Assert.Same(secondReal, plate.Parts[2]);
Assert.Same(secondProgram, secondReal.Program);
Assert.Equal(secondLocation, secondReal.Location);
Assert.Same(program, real.Program);
Assert.Equal(text, real.Program.ToString());
Assert.Equal(location, real.Location);
Assert.Equal(nested, real.BaseDrawing.Quantity.Nested);
Assert.Equal(5, manual.StartLimit);
Assert.Equal(15, manual.EndLimit);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void EquivalentFullSpanLimits_AndCoordinateTolerance_SuppressDuplicates(bool explicitLimits)
{
var plate = MakePlate();
var existing = new CutOff(new Vector(35 + Tolerance.Epsilon / 2, 999), CutOffAxis.Vertical);
if (explicitLimits)
{
existing.StartLimit = 0;
existing.EndLimit = 83;
}
plate.CutOffs.Add(existing);
var plan = Plan(plate, settings: new CutOffSettings { Overtravel = 2 });
Assert.Equal(new[] { 70, 80.5 + Tolerance.Epsilon }, Positions(plan));
Assert.False(plan.HasBlockingDiagnostics);
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff);
}
[Fact]
public void DifferentAxisOrDifferentCoordinate_DoesNotSuppress()
{
var plate = MakePlate();
plate.CutOffs.Add(new CutOff(new Vector(35, 35), CutOffAxis.Horizontal));
plate.CutOffs.Add(new CutOff(new Vector(35.01, 0), CutOffAxis.Vertical));
Assert.Equal(3, Plan(plate).Definitions.Count);
}
[Theory]
[InlineData(35)]
[InlineData(80.50001)]
public void SameLineLimitedManualCut_BlocksWholePlan_WithoutMutatingManual(double x)
{
var plate = MakePlate();
var manual = new CutOff(new Vector(x, 0), CutOffAxis.Vertical)
{ StartLimit = 10, EndLimit = 60 };
plate.CutOffs.Add(manual);
var program = manual.Drawing.Program;
var plan = Plan(plate);
Assert.True(plan.HasBlockingDiagnostics);
Assert.Empty(plan.Definitions);
Assert.Empty(plan.PreviewParts);
Assert.False(plan.HasSeparatedTail);
Assert.Contains(plan.Diagnostics, d => d.IsBlocking &&
d.Code == AutomaticCutOffDiagnosticCode.LimitedCutOffConflict);
Assert.Same(program, manual.Drawing.Program);
Assert.Equal(10, manual.StartLimit);
Assert.Equal(60, manual.EndLimit);
}
[Fact]
public void ExistingFullSpanAlongsideLimitedConflict_DoesNotHideConflict()
{
var plate = MakePlate();
plate.CutOffs.Add(new CutOff(new Vector(35, 0), CutOffAxis.Vertical));
plate.CutOffs.Add(new CutOff(new Vector(35, 0), CutOffAxis.Vertical) { EndLimit = 40 });
Assert.True(Plan(plate).HasBlockingDiagnostics);
}
[Fact]
public void ExistingSeparator_MetadataUsesItsActualToleranceCloseCoordinate()
{
var plate = MakePlate();
var x = 80.5 + Tolerance.Epsilon * 1.5;
plate.CutOffs.Add(new CutOff(new Vector(x, 0), CutOffAxis.Vertical));
var plan = Plan(plate);
Assert.True(plan.HasSeparatedTail);
Assert.Equal(x, plan.TailSeparatorX);
Assert.Equal(x, plan.UsedSpan);
Assert.Equal(120 - x, plan.TailLength);
}
[Fact]
public void NearEquivalentExistingSeparator_ThatStillTouchesClearance_DoesNotClaimTail()
{
var plate = MakePlate();
plate.PartSpacing = 0;
plate.CutOffs.Add(new CutOff(new Vector(80, 0), CutOffAxis.Vertical));
var plan = Plan(plate, settings: new CutOffSettings { PartClearance = 0 });
Assert.False(plan.HasSeparatedTail);
Assert.Equal(0, plan.TailLength);
Assert.DoesNotContain(plan.Definitions, c => c.Position.X > 70);
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
}
[Fact]
public void FractionalPitch_UsesIntegerMultiplicationWithoutCumulativeDrift()
{
var plate = MakePlate(occupied: 11);
var plan = Plan(plate, spacing: 0.7);
var repeated = plan.Definitions.Take(plan.Definitions.Count - 1).ToArray();
Assert.NotEmpty(repeated);
for (var i = 0; i < repeated.Length; i++)
Assert.Equal((i + 1) * 0.7, repeated[i].Position.X);
}
[Fact]
public void ConcaveOrSlopedPart_UsesPerimeterNotJustItsBoundingBox()
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(30, 0));
program.Codes.Add(new LinearMove(0, 30));
program.Codes.Add(new LinearMove(0, 0));
var plate = new Plate(81, 120);
plate.Parts.Add(new Part(new Drawing("triangle", program), new Vector(10, 10)));
var plan = Plan(plate, spacing: 20, settings: new CutOffSettings { PartClearance = 0 });
var segments = Segments(plan.Definitions[0].Drawing.Program);
Assert.Equal(2, segments.Length);
Assert.Equal(30, segments[1].From.Y, 8); // Bounding-box fallback would start at 40.
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.SegmentedCut);
}
[Fact]
public void ValidButStalePartBounds_AreRejectedRatherThanUsingUnsafeBroadphase()
{
var plate = MakePlate();
((Motion)plate.Parts[0].Program.Codes[2]).EndPoint = new Vector(90, 20);
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
}
private static Plate MakeArcPlate(double centerError)
{
var program = new Program();
program.Codes.Add(new RapidMove(0, 0));
program.Codes.Add(new LinearMove(10, 0));
program.Codes.Add(new ArcMove(10, 20, 10, 10 + centerError, RotationType.CCW));
program.Codes.Add(new LinearMove(0, 20));
program.Codes.Add(new LinearMove(0, 0));
var material = ConvertProgram.ToGeometry(program).Where(e => SpecialLayers.IsMaterial(e.Layer));
Assert.True(Assert.Single(ShapeBuilder.GetShapes(material)).IsClosed());
var drawing = new Drawing("D shape", program);
Assert.Equal(200 + 50 * System.Math.PI, drawing.Area, 2); // Drawing area tessellates arcs.
var plate = new Plate(81, 120) { PartSpacing = 0 };
plate.Parts.Add(new Part(drawing, new Vector(10, 10)));
return plate;
}
[Theory]
[InlineData(0.0001, false)]
[InlineData(0.0000075, true)]
public void ConvertedArcOutsideCachedBounds_IsRejectedBeforeAcceptance(double centerError, bool repeatedCut)
{
var plate = MakeArcPlate(centerError);
var real = plate.Parts[0];
var cached = real.BoundingBox;
var raw = real.Program.BoundingBox().Translate(real.Location);
Assert.Equal(raw.Right, cached.Right); // Neither cache refresh nor raw-program bounds reveal the error.
Assert.Equal(30 - centerError, cached.Right, 10);
var arc = Assert.Single(ConvertProgram.ToGeometry(real.Program).OfType<Arc>());
arc.Offset(real.Location);
Assert.Equal(30, arc.BoundingBox.Right);
var protrusion = arc.BoundingBox.Right - cached.Right;
Assert.True(protrusion > 0);
Assert.Equal(repeatedCut, protrusion < AutomaticCutOffPlanner.GeometryTolerance);
// A separator crosses the larger protrusion. Even sub-tolerance protrusions are
// unsafe when a repeated line falls between the cached and converted bounds.
var unsafeX = cached.Right + (repeatedCut ? protrusion / 2 : AutomaticCutOffPlanner.GeometryTolerance);
var unsafeLine = new Line(new Vector(unsafeX, 0), new Vector(unsafeX, 81));
Assert.True(arc.Intersects(unsafeLine, out var intersections));
Assert.Equal(2, intersections.Count);
if (repeatedCut)
plate.Parts.Add(Rectangle(10, 10, new Vector(60, 50)));
var settings = new CutOffSettings { PartClearance = 0 };
var manual = new CutOff(new Vector(100, 0), CutOffAxis.Vertical);
plate.CutOffs.Add(manual);
plate.RegenerateCutOffs(settings);
var parts = plate.Parts.ToArray();
var definitions = plate.CutOffs.ToArray();
var programs = parts.Select(p => p.Program).ToArray();
var text = programs.Select(p => p.ToString()).ToArray();
var locations = parts.Select(p => p.Location).ToArray();
var bounds = parts.Select(p => p.BoundingBox).ToArray();
var manualProgram = manual.Drawing.Program;
var originalCenter = Assert.IsType<ArcMove>(real.Program.Codes[2]).CenterPoint;
var changes = 0;
plate.PartAdded += (_, _) => changes++;
plate.PartRemoved += (_, _) => changes++;
plate.PartChanged += (_, _) => changes++;
AutomaticCutOffPlan? plan = null;
Assert.Throws<ArgumentException>(() =>
{
plan = Plan(plate, spacing: repeatedCut ? unsafeX : 35, settings: settings);
foreach (var definition in plan.Definitions)
plate.CutOffs.Add(definition);
plate.RegenerateCutOffs(settings);
});
Assert.Null(plan); // No unsafe proposal can escape to the normal acceptance path.
Assert.Equal(parts, plate.Parts.ToArray());
Assert.Equal(definitions, plate.CutOffs.ToArray());
Assert.Equal(programs, plate.Parts.Select(p => p.Program));
Assert.Equal(text, plate.Parts.Select(p => p.Program.ToString()));
Assert.Equal(locations, plate.Parts.Select(p => p.Location));
Assert.Equal(bounds, plate.Parts.Select(p => p.BoundingBox));
Assert.Same(manualProgram, manual.Drawing.Program);
Assert.Equal(originalCenter.X, Assert.IsType<ArcMove>(real.Program.Codes[2]).CenterPoint.X);
Assert.Equal(originalCenter.Y, Assert.IsType<ArcMove>(real.Program.Codes[2]).CenterPoint.Y);
Assert.Equal(0, changes);
}
[Fact]
public void LargeArcRefit_DoesNotReplaceConvertedPerimeterWithUndersizedBox()
{
var plate = MakeArcPlate(0.0001);
var part = Assert.Single(plate.Parts);
var perimeter = Plate.BuildPerimeterCache(plate)[part];
Assert.NotNull(perimeter);
Assert.True(perimeter.BoundingBox.Right > part.BoundingBox.Right + CutOff.GetBoundsRoundoff(part));
var arc = Assert.Single(ConvertProgram.ToGeometry(part.Program).OfType<Arc>());
arc.Offset(part.Location);
var x = (part.BoundingBox.Right + arc.BoundingBox.Right) / 2;
var manual = new CutOff(new Vector(x, 0), CutOffAxis.Vertical);
manual.Regenerate(plate, new CutOffSettings { PartClearance = 0 }, Plate.BuildPerimeterCache(plate));
foreach (var segment in Segments(manual.Drawing.Program))
Assert.False(arc.Intersects(new Line(segment.From, segment.To), out _));
}
[Fact]
public void LargeArcRefit_UncachedDirectRegenerate_DoesNotCrossConvertedPerimeter()
{
// Same large-refit scenario as above, but the caller never built a
// perimeter cache at all (OpenNest PM 13f7a685: a direct/uncached
// CutOff.Regenerate call used to trust the bare BoundingBox and could
// emit a segment crossing the arc's true converted extent).
var plate = MakeArcPlate(0.0001);
var part = Assert.Single(plate.Parts);
var cachedBox = part.BoundingBox;
var arc = Assert.Single(ConvertProgram.ToGeometry(part.Program).OfType<Arc>());
arc.Offset(part.Location);
var x = (cachedBox.Right + arc.BoundingBox.Right) / 2;
Assert.True(x > cachedBox.Right); // The unsafe line sits outside the cached bounds.
var manual = new CutOff(new Vector(x, 0), CutOffAxis.Vertical);
manual.Regenerate(plate, new CutOffSettings { PartClearance = 0 }); // No cache argument.
foreach (var segment in Segments(manual.Drawing.Program))
Assert.False(arc.Intersects(new Line(segment.From, segment.To), out _));
}
[Fact]
public void ExactArcWithinCachedBounds_PlansAndMaterializesSafeCuts()
{
var plate = MakeArcPlate(0);
var real = plate.Parts[0];
var program = real.Program;
var settings = new CutOffSettings { PartClearance = 0 };
var plan = Plan(plate, spacing: 25, settings: settings);
Assert.True(plan.HasSeparatedTail);
Assert.False(plan.HasBlockingDiagnostics);
Assert.Equal(30, plan.OccupiedSpan);
Assert.Equal(new[] { 25, 30 + AutomaticCutOffPlanner.GeometryTolerance }, Positions(plan));
Assert.Same(real, Assert.Single(plate.Parts));
Assert.Empty(plate.CutOffs);
var previewSegments = plan.PreviewParts.Select(p => Segments(p.Program)).ToArray();
foreach (var definition in plan.Definitions)
plate.CutOffs.Add(definition);
plate.RegenerateCutOffs(settings);
Assert.Equal(TestHelpers.CutOffPartCount(plate), plate.Parts.Count(p => p.BaseDrawing.IsCutOff));
for (var i = 0; i < plan.Definitions.Count; i++)
Assert.Equal(previewSegments[i], Segments(TestHelpers.CutOffProgram(plate, plan.Definitions[i])));
var repeated = Segments(TestHelpers.CutOffProgram(plate, plan.Definitions[0]));
Assert.Equal(2, repeated.Length);
Assert.Equal(20 - System.Math.Sqrt(75), repeated[0].To.Y, 8);
Assert.Equal(20 + System.Math.Sqrt(75), repeated[1].From.Y, 8);
var separator = Assert.Single(Segments(TestHelpers.CutOffProgram(plate, plan.Definitions[1])));
Assert.Equal(new Vector(plan.TailSeparatorX!.Value, 0), separator.From);
Assert.Equal(new Vector(plan.TailSeparatorX.Value, 81), separator.To);
var arc = Assert.Single(ConvertProgram.ToGeometry(real.Program).OfType<Arc>());
arc.Offset(real.Location);
Assert.False(arc.Intersects(new Line(separator.From, separator.To), out _));
Assert.Same(real, plate.Parts[0]);
Assert.Same(program, real.Program);
var repeatedPlan = Plan(plate, spacing: 25, settings: settings);
Assert.Empty(repeatedPlan.Definitions);
Assert.True(repeatedPlan.HasSeparatedTail);
Assert.Equal(plan.TailSeparatorX, repeatedPlan.TailSeparatorX);
}
[Fact]
public void InvalidArcCenterAndRecursiveProgram_AreRejectedBeforeGeometryConversion()
{
var plate = MakePlate();
plate.Parts[0].Program.Codes.Add(new ArcMove(new Vector(), new Vector(double.NaN, 0)));
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
plate = MakePlate();
var program = plate.Parts[0].Program;
program.Codes.Add(new SubProgramCall { Program = program });
Assert.ThrowsAny<ArgumentException>(() => Plan(plate));
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(3)]
[InlineData(4)]
public void ApplyAndRepeat_IsIdempotentInEveryQuadrant(int quadrant)
{
var plate = MakePlate(quadrant);
var settings = new CutOffSettings();
var first = Plan(plate, settings: settings);
foreach (var definition in first.Definitions)
plate.CutOffs.Add(definition);
plate.RegenerateCutOffs(settings);
var second = Plan(plate, settings: settings);
Assert.Empty(second.Definitions);
Assert.True(second.HasSeparatedTail);
Assert.Equal(first.TailSeparatorX, second.TailSeparatorX);
Assert.Equal(first.TailLength, second.TailLength);
Assert.Equal(TestHelpers.CutOffPartCount(plate), plate.Parts.Count(p => p.BaseDrawing.IsCutOff));
}
[Fact]
public void ExistingSeparator_IsVerifiedUnderCurrentSettings_NotStaleProgram()
{
var plate = MakePlate();
var existing = new CutOff(new Vector(80.5 + Tolerance.Epsilon, 0), CutOffAxis.Vertical);
existing.Regenerate(plate, new CutOffSettings());
Assert.NotEmpty(existing.Drawing.Program.Codes);
plate.CutOffs.Add(existing);
var program = existing.Drawing.Program;
var plan = Plan(plate, settings: new CutOffSettings { MinSegmentLength = 100 });
Assert.False(plan.HasSeparatedTail);
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
Assert.Same(program, existing.Drawing.Program);
}
}