using System.IO.Compression; using System.Text.Json; using OpenNest.CNC; using OpenNest.Converters; using OpenNest.Geometry; using OpenNest.IO; using OpenNest.Math; namespace OpenNest.Tests.CutOffs; public class AutomaticCutOffLifecycleTests { [Theory] [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] public void Apply_AppendsPlannedDefinitions_AfterExistingMixedSequence(int quadrant) { var settings = new CutOffSettings { PartClearance = 0.75, Overtravel = 2 }; var nest = MakeNest(quadrant, settings); var plate = Assert.Single(nest.Plates); var realParts = CaptureRealParts(plate); var manual = Assert.Single(plate.CutOffs); var manualDefinition = Definition(manual); var originalSequence = plate.Parts.ToArray(); var plan = CreatePlan(plate, settings); Assert.Equal(originalSequence, plate.Parts.ToArray()); Assert.Same(manual, Assert.Single(plate.CutOffs)); Assert.Equal(3, plan.PreviewParts.Count); Assert.All(plan.PreviewParts, preview => Assert.DoesNotContain(preview, plate.Parts)); var previewPrograms = plan.PreviewParts.Select(p => NestWriter.GetProgramText(p.Program)).ToArray(); Apply(plate, plan, settings); Assert.Equal(new[] { manual }.Concat(plan.Definitions), plate.CutOffs); Assert.Equal(manualDefinition, Definition(manual)); AssertSequence(plate, realParts[0].Part, realParts[1].Part); AssertOnePartPerDefinition(plate); AssertRealPartsUnchanged(plate, realParts); for (var i = 0; i < plan.Definitions.Count; i++) { var materialized = plate.Parts[i + 3]; Assert.Same(plan.Definitions[i].Drawing, materialized.BaseDrawing); Assert.NotSame(plan.PreviewParts[i], materialized); Assert.Equal(previewPrograms[i], NestWriter.GetProgramText(materialized.Program)); } var repeated = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, settings); Assert.Empty(repeated.Definitions); Assert.Empty(repeated.PreviewParts); Assert.False(repeated.HasBlockingDiagnostics); Assert.True(repeated.HasSeparatedTail); Assert.Equal(plan.TailSeparatorX, repeated.TailSeparatorX); Assert.Equal(plan.UsedSpan, repeated.UsedSpan); Assert.Equal(plan.TailLength, repeated.TailLength); Assert.Equal(3, repeated.Diagnostics.Count(d => d.Code == AutomaticCutOffDiagnosticCode.ExistingCutOff)); // Accepting the empty repeat and regenerating must not duplicate or resequence anything. Apply(plate, repeated, settings); Assert.Equal(new[] { manual }.Concat(plan.Definitions), plate.CutOffs); AssertSequence(plate, realParts[0].Part, realParts[1].Part); AssertOnePartPerDefinition(plate); AssertRealPartsUnchanged(plate, realParts); } [Theory] [InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)] public void SaveAndReload_PreservesDefinitionsLimitsAndExactMixedSequence(int quadrant) { // NestReader uses default CutOffSettings; nondefault toolpath fidelity is not this contract. var settings = new CutOffSettings(); var nest = MakeNest(quadrant, settings); var plate = Assert.Single(nest.Plates); var realParts = CaptureRealParts(plate); Apply(plate, CreatePlan(plate, settings), settings); var definitions = plate.CutOffs.Select(Definition).ToArray(); using var stream = new MemoryStream(); Assert.True(new NestWriter(nest).Write(stream)); AssertArchiveContainsOnlyRealParts(stream, plate); stream.Position = 0; var loaded = new NestReader(stream).Read(); var loadedPlate = Assert.Single(loaded.Plates); var loadedRealParts = loadedPlate.Parts.Where(p => !p.BaseDrawing.IsCutOff).ToArray(); Assert.Equal(quadrant, loadedPlate.Quadrant); Assert.Equal(plate.Size, loadedPlate.Size); Assert.Equal(plate.PartSpacing, loadedPlate.PartSpacing); Assert.Equal(plate.Quantity, loadedPlate.Quantity); Assert.Equal(definitions, loadedPlate.CutOffs.Select(Definition)); Assert.Equal(realParts.Select(p => p.Location), loadedRealParts.Select(p => p.Location)); Assert.Equal(realParts.Select(p => p.ProgramText), loadedRealParts.Select(p => NestWriter.GetProgramText(p.Program))); Assert.Equal(realParts.Select(p => p.Rotation), loadedRealParts.Select(p => p.Rotation)); AssertSequence(loadedPlate, loadedRealParts[0], loadedRealParts[1]); AssertOnePartPerDefinition(loadedPlate); var loadedDrawing = Assert.Single(loaded.Drawings); Assert.False(loadedDrawing.IsCutOff); Assert.All(loadedRealParts, p => Assert.Same(loadedDrawing, p.BaseDrawing)); Assert.Equal(realParts[0].Required, loadedDrawing.Quantity.Required); Assert.Equal(realParts[0].Nested, loadedDrawing.Quantity.Nested); AssertRealPartsUnchanged(plate, realParts); var loadedState = CaptureRealParts(loadedPlate); loadedPlate.RegenerateCutOffs(settings); Assert.Equal(definitions, loadedPlate.CutOffs.Select(Definition)); AssertSequence(loadedPlate, loadedRealParts[0], loadedRealParts[1]); AssertOnePartPerDefinition(loadedPlate); AssertRealPartsUnchanged(loadedPlate, loadedState); } [Theory] [InlineData(1, 5)] [InlineData(2, 5)] [InlineData(3, 5)] [InlineData(4, 5)] [InlineData(1, 10)] [InlineData(2, 10)] [InlineData(3, 10)] [InlineData(4, 10)] public void MovePartAfterReload_RegeneratesSafeSegments_WithoutMovingDefinitions(int quadrant, double offsetX) { var settings = new CutOffSettings(); var nest = MakeNest(quadrant, settings); var source = Assert.Single(nest.Plates); Apply(source, CreatePlan(source, settings), settings); using var stream = new MemoryStream(); Assert.True(new NestWriter(nest).Write(stream)); stream.Position = 0; var loaded = new NestReader(stream).Read(); var plate = Assert.Single(loaded.Plates); var definitions = plate.CutOffs.ToArray(); var definitionValues = definitions.Select(Definition).ToArray(); var realParts = CaptureRealParts(plate); var moved = realParts[1].Part; var separator = definitions[^1]; var separatorPart = plate.Parts[^1]; var before = Assert.Single(Segments(separatorPart)); var bounds = plate.BoundingBox(false); Assert.Equal(bounds.Bottom, before.From.Y); Assert.Equal(bounds.Top, before.To.Y); Assert.Equal(separator.Position.X, before.From.X); Assert.Equal(separator.Position.X, before.To.X); AssertCutsAvoidRectangles(plate, settings.PartClearance); // Move the outer part across the old separator: its fixed line must now be trimmed, // not shifted to a new tail or left as a stale full-width cut through the part. var offset = new Vector(quadrant is 2 or 3 ? -offsetX : offsetX, quadrant is 3 or 4 ? -10 : 10); moved.Offset(offset); Assert.Equal(realParts[1].Location + offset, moved.Location); var movedState = CaptureRealParts(plate); plate.RegenerateCutOffs(settings); Assert.Equal(definitions, plate.CutOffs.ToArray()); Assert.Equal(definitionValues, plate.CutOffs.Select(Definition)); AssertSequence(plate, realParts[0].Part, moved); AssertOnePartPerDefinition(plate); AssertRealPartsUnchanged(plate, movedState); AssertRealPartsUnchanged(plate, new[] { realParts[0], realParts[1] with { Location = moved.Location } }); Assert.NotSame(separatorPart, plate.Parts[^1]); Assert.DoesNotContain(separatorPart, plate.Parts); var after = Segments(plate.Parts[^1]); Assert.Equal(2, after.Length); Assert.All(after, s => { Assert.Equal(separator.Position.X, s.From.X); Assert.Equal(separator.Position.X, s.To.X); }); AssertCutsAvoidRectangles(plate, settings.PartClearance); var regeneratedPrograms = plate.Parts.Where(p => p.BaseDrawing.IsCutOff) .Select(p => NestWriter.GetProgramText(p.Program)).ToArray(); plate.RegenerateCutOffs(settings); Assert.Equal(definitionValues, plate.CutOffs.Select(Definition)); Assert.Equal(regeneratedPrograms, plate.Parts.Where(p => p.BaseDrawing.IsCutOff) .Select(p => NestWriter.GetProgramText(p.Program))); AssertSequence(plate, realParts[0].Part, moved); AssertOnePartPerDefinition(plate); AssertRealPartsUnchanged(plate, movedState); AssertCutsAvoidRectangles(plate, settings.PartClearance); } private static Nest MakeNest(int quadrant, CutOffSettings settings) { var program = new Program(); program.Codes.Add(new RapidMove(0, 0)); program.Codes.Add(new LinearMove(20, 0)); program.Codes.Add(new LinearMove(20, 20)); program.Codes.Add(new LinearMove(0, 20)); program.Codes.Add(new LinearMove(0, 0)); Assert.True(Assert.Single(ShapeBuilder.GetShapes(ConvertProgram.ToGeometry(program))).IsClosed()); var drawing = new Drawing("square", program); Assert.Equal(400, drawing.Area); drawing.Quantity.Required = 10; var nest = new Nest("automatic-cutoff-lifecycle"); nest.Drawings.Add(drawing); var plate = new Plate(81, 120) { Quadrant = quadrant, PartSpacing = 0.5, Quantity = 2 }; var negativeX = quadrant is 2 or 3; var negativeY = quadrant is 3 or 4; var first = new Part(drawing, new Vector(negativeX ? -30 : 10, negativeY ? -40 : 20)); var second = new Part(drawing, new Vector(negativeX ? -80 : 60, negativeY ? -40 : 20)); plate.Parts.Add(first); plate.Parts.Add(second); var manual = new CutOff(new Vector(negativeX ? -15 : 15, negativeY ? -10 : 10), CutOffAxis.Horizontal) { StartLimit = negativeX ? -25 : 5, EndLimit = negativeX ? -5 : 25, }; plate.CutOffs.Add(manual); plate.RegenerateCutOffs(settings); var manualPart = plate.Parts[^1]; plate.Parts.Remove(manualPart); plate.Parts.Insert(1, manualPart); Assert.Equal(new[] { first, manualPart, second }, plate.Parts); Assert.Equal(4, drawing.Quantity.Nested); nest.Plates.Add(plate); return nest; } private static AutomaticCutOffPlan CreatePlan(Plate plate, CutOffSettings settings) { var plan = AutomaticCutOffPlanner.Create(plate, new AutomaticCutOffOptions { Spacing = 35 }, settings); var sign = plate.Quadrant is 2 or 3 ? -1 : 1; var separator = 80 + System.Math.Max(plate.PartSpacing, settings.PartClearance) + Tolerance.Epsilon; Assert.False(plan.HasBlockingDiagnostics); Assert.True(plan.HasSeparatedTail); Assert.Equal(80, plan.OccupiedSpan); Assert.Equal(separator, plan.UsedSpan); Assert.Equal(120 - separator, plan.TailLength); Assert.Equal(sign * separator, plan.TailSeparatorX); Assert.Equal(new[] { sign * 35.0, sign * 70.0, sign * separator }, plan.Definitions.Select(c => c.Position.X)); Assert.All(plan.Definitions, c => { Assert.Equal(CutOffAxis.Vertical, c.Axis); Assert.Null(c.StartLimit); Assert.Null(c.EndLimit); }); return plan; } private static void Apply(Plate plate, AutomaticCutOffPlan plan, CutOffSettings settings) { Assert.False(plan.HasBlockingDiagnostics); foreach (var definition in plan.Definitions) plate.CutOffs.Add(definition); plate.RegenerateCutOffs(settings); } private static void AssertSequence(Plate plate, Part first, Part second) => Assert.Collection(plate.Parts, p => Assert.Same(first, p), p => Assert.Same(plate.CutOffs[0].Drawing, p.BaseDrawing), p => Assert.Same(second, p), p => Assert.Same(plate.CutOffs[1].Drawing, p.BaseDrawing), p => Assert.Same(plate.CutOffs[2].Drawing, p.BaseDrawing), p => Assert.Same(plate.CutOffs[3].Drawing, p.BaseDrawing)); private static void AssertOnePartPerDefinition(Plate plate) { Assert.Equal(4, plate.CutOffs.Count); Assert.Equal(4, plate.Parts.Count(p => p.BaseDrawing.IsCutOff)); Assert.All(plate.CutOffs, c => { Assert.True(c.Drawing.IsCutOff); Assert.Single(plate.Parts.Where(p => ReferenceEquals(p.BaseDrawing, c.Drawing))); }); } private static (double X, double Y, CutOffAxis Axis, double? Start, double? End) Definition(CutOff cut) => (cut.Position.X, cut.Position.Y, cut.Axis, cut.StartLimit, cut.EndLimit); private sealed record RealPartState(Part Part, Program Program, string ProgramText, Vector Location, double Rotation, Program DrawingProgram, string DrawingText, int Required, int Nested); private static RealPartState[] CaptureRealParts(Plate plate) => plate.Parts .Where(p => !p.BaseDrawing.IsCutOff) .Select(p => new RealPartState(p, p.Program, NestWriter.GetProgramText(p.Program), p.Location, p.Rotation, p.BaseDrawing.Program, NestWriter.GetProgramText(p.BaseDrawing.Program), p.BaseDrawing.Quantity.Required, p.BaseDrawing.Quantity.Nested)).ToArray(); private static void AssertRealPartsUnchanged(Plate plate, RealPartState[] expected) { Assert.Equal(expected.Select(s => s.Part), plate.Parts.Where(p => !p.BaseDrawing.IsCutOff)); Assert.All(expected, s => { Assert.Same(s.Program, s.Part.Program); Assert.Equal(s.ProgramText, NestWriter.GetProgramText(s.Part.Program)); Assert.Equal(s.Location, s.Part.Location); Assert.Equal(s.Rotation, s.Part.Rotation); Assert.Same(s.DrawingProgram, s.Part.BaseDrawing.Program); Assert.Equal(s.DrawingText, NestWriter.GetProgramText(s.Part.BaseDrawing.Program)); Assert.Equal(s.Required, s.Part.BaseDrawing.Quantity.Required); Assert.Equal(s.Nested, s.Part.BaseDrawing.Quantity.Nested); }); } private static void AssertArchiveContainsOnlyRealParts(MemoryStream stream, Plate plate) { stream.Position = 0; using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: true); using var reader = new StreamReader(archive.GetEntry("nest.json")!.Open()); using var json = JsonDocument.Parse(reader.ReadToEnd()); var savedPlate = Assert.Single(json.RootElement.GetProperty("plates").EnumerateArray()); var savedParts = savedPlate.GetProperty("parts").EnumerateArray().ToArray(); Assert.Equal(2, savedParts.Length); Assert.Single(json.RootElement.GetProperty("drawings").EnumerateArray()); Assert.Single(archive.Entries.Where(e => e.FullName.StartsWith("programs/", StringComparison.Ordinal))); Assert.DoesNotContain(archive.Entries, e => e.FullName.StartsWith("parts/", StringComparison.Ordinal)); var realParts = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff).ToArray(); for (var i = 0; i < realParts.Length; i++) { Assert.Equal(realParts[i].Location.X, savedParts[i].GetProperty("x").GetDouble()); Assert.Equal(realParts[i].Location.Y, savedParts[i].GetProperty("y").GetDouble()); } var cuts = savedPlate.GetProperty("cutOffs").EnumerateArray().ToArray(); Assert.Equal(new[] { 1, 3, 4, 5 }, cuts.Select(c => c.GetProperty("sequence").GetInt32())); } private static (Vector From, Vector To)[] Segments(Part part) { Assert.NotEmpty(part.Program.Codes); Assert.Equal(0, part.Program.Codes.Count % 2); return Enumerable.Range(0, part.Program.Codes.Count / 2).Select(i => ( Assert.IsType(part.Program.Codes[2 * i]).EndPoint + part.Location, Assert.IsType(part.Program.Codes[2 * i + 1]).EndPoint + part.Location)).ToArray(); } private static void AssertCutsAvoidRectangles(Plate plate, double clearance) { // Independent axis-aligned rectangle oracle: inspect every emitted cutting move, // not rapids, counts, or the same CutOff.ComputeSegments algorithm under test. var rectangles = plate.Parts.Where(p => !p.BaseDrawing.IsCutOff).Select(p => p.BoundingBox).ToArray(); foreach (var cutPart in plate.Parts.Where(p => p.BaseDrawing.IsCutOff)) foreach (var segment in Segments(cutPart)) { var minX = System.Math.Min(segment.From.X, segment.To.X); var maxX = System.Math.Max(segment.From.X, segment.To.X); var minY = System.Math.Min(segment.From.Y, segment.To.Y); var maxY = System.Math.Max(segment.From.Y, segment.To.Y); Assert.True(minX == maxX || minY == maxY); Assert.True(minX < maxX || minY < maxY); Assert.All(rectangles, box => Assert.True( maxX <= box.Left - clearance || minX >= box.Right + clearance || maxY <= box.Bottom - clearance || minY >= box.Top + clearance, $"Cut {segment.From} -> {segment.To} crosses a rectangle or its clearance.")); } } }