Files
OpenNest/OpenNest.Tests/CutOffs/AutomaticCutOffPlannerTests.cs
T
aj 9a293bf221 fix(core): compare clamp cut checks in the plate's quadrant frame
Plate.ClampZones are stored relative to the sheet's lower-left corner,
but cut-off positions, motions and part poses use the signed quadrant
frame. The automatic cutoff planner, manual cut-off contacts, clamp
rapid warnings and whole-plate overlap capture compared the two
directly, so in quadrants 2-4 a cut or rapid through a real rail clamp
was neither avoided nor warned about.

Add ClampZoneFrame.ToPlacement, which maps live zones into the
placement frame exactly like the sheet and engine stock, and use it in
every Slice 5 consumer. It rejects missing or malformed zones (bounds
stay publicly mutable); pre-post verification reports those as an
incomplete clamp check instead of a clear result. Overlap freshness now
treats a size or quadrant change as moving the zones.

Tests use real ClampRail zones in all quadrants; the hand-signed
quadrant fixtures that hid the defect are converted to rails.

Project Memory: opennest 274836d5-fb76-4619-b3cc-0c811e767dd6
2026-10-13 18:15:49 -04:00

953 lines
41 KiB
C#

using System.Reflection;
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 (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_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)]
[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_SkipsNominalLineAndRefusesTailSeparator(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.
plate.SetClampRail(new ClampRail(0.2, 0.625, new[] { 35.2, 80.7 }));
var before = plate.Parts.ToArray();
var plan = Plan(plate);
Assert.Equal(new[] { sign * 70.0 }, Positions(plan));
Assert.False(plan.HasSeparatedTail);
Assert.Equal(0, plan.TailLength);
Assert.Contains(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_AndPartialFootprintBlocks()
{
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);
Assert.False(plan.HasSeparatedTail);
Assert.Null(plan.TailSeparatorX);
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
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 RailClampsBlockGridAndTailInEveryQuadrant(int quadrant)
{
// Rail clamps centered 35 and 80.5 from the origin, 1.5 wide, plus 0.25 clearance:
// the grid line at 35 and the separator at 80.50001 both cross a clamp.
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);
Assert.Equal(new[] { sign * 70.0 }, Positions(plan));
Assert.False(plan.HasSeparatedTail);
Assert.Equal(2, plan.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.ClampBlockedCut));
Assert.Contains(plan.Diagnostics, d => d.Code == AutomaticCutOffDiagnosticCode.NoSafeTailSeparator);
}
[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);
}
[Theory]
[InlineData(35)]
[InlineData(80.50001)]
public void SameLineLimitedManualCutOnClamp_StillBlocksWholePlan(double x)
{
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(4, plate.Parts.Count(p => p.BaseDrawing.IsCutOff));
Assert.All(plate.CutOffs, c => Assert.Single(plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, c.Drawing))));
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 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);
var cuts = plate.Parts.Where(p => p.BaseDrawing.IsCutOff).ToArray();
Assert.Equal(plan.Definitions.Count, cuts.Length);
for (var i = 0; i < cuts.Length; i++)
Assert.Equal(previewSegments[i], Segments(cuts[i].Program));
var repeated = Segments(cuts[0].Program);
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(cuts[1].Program));
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(3, 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);
}
}