diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs index 27548240..fbc43007 100644 --- a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs +++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs @@ -135,6 +135,11 @@ public static class PostVerificationAnalyzer if (part == null || !double.IsFinite(part.Rotation)) throw new ArgumentException("Missing part or invalid rotation."); var moves = ExecutionMotionReader.Read(part.Program, part.Location, position, cancellationToken).Motions; + // Executed reads stop at an applied program's display tail, so any display + // perimeter left is an unplanned blank whose outline a post would refuse. + if (moves.Any(move => move.Layer == LayerType.DisplayPerimeter)) + Incomplete("This part has a display perimeter (a supplied blank's outline that is never cut) " + + "but no cutting plan; posts refuse it. Run Plan Cutting so only its cutouts are cut."); if (expectsCuts && !moves.Any(move => !move.Rapid && move.Layer is LayerType.Cut or LayerType.Display && move.Curve.Length > PostVerificationGeometry.Epsilon)) diff --git a/OpenNest.Tests/CuttingPlanning/DisplayPerimeterPostTests.cs b/OpenNest.Tests/CuttingPlanning/DisplayPerimeterPostTests.cs new file mode 100644 index 00000000..6080951a --- /dev/null +++ b/OpenNest.Tests/CuttingPlanning/DisplayPerimeterPostTests.cs @@ -0,0 +1,102 @@ +using System.Text; +using OpenNest.CNC; +using OpenNest.CNC.CuttingPlanning; +using OpenNest.Diagnostics; +using OpenNest.Engine.CuttingPlanning; +using OpenNest.Geometry; +using OpenNest.Posts.Cincinnati; +using OpenNest.Posts.CincinnatiCIFiber; +using OpenNest.Posts.GravographIS; + +namespace OpenNest.Tests.CuttingPlanning; + +/// No post may cut a supplied blank's display perimeter, planned or not. +public class DisplayPerimeterPostTests +{ + public static IEnumerable Posts() => [["cincinnati"], ["cifiber"], ["gravograph"]]; + + private static IPostProcessor Post(string name) => name switch + { + "cincinnati" => new CincinnatiPostProcessor(new CincinnatiPostConfig { ConfigurationName = "CL940", PostedAccuracy = 4 }), + "cifiber" => new CIFiberPostProcessor(new CIFiberPostConfig { ConfigurationName = "CI FIBER 8K" }), + _ => new GravographISPostProcessor(), + }; + + private static string Output(string post, Nest nest) + { + // The engraver's writer refuses this fixture's sheet for its work envelope, so compare the + // geometry its extractor hands the writer: the step that reads part programs. + if (post == "gravograph") + return string.Join("\n", new NestPolylineExtractor().ExtractLayered(nest).Select(poly => + poly.Layer + ":" + string.Join(";", poly.Points.Select(v => FormattableString.Invariant($"{v.X:R},{v.Y:R}"))))); + using var stream = new MemoryStream(); + Post(post).Post(nest, stream); + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static (Nest Nest, Part Part) DoorNest() + { + var nest = new Nest("Door") { Units = Units.Inches }; + var drawing = new Drawing("door", DisplayPerimeterTests.Door()); + nest.Drawings.Add(drawing); + var plate = new Plate(new Size(20, 30)); + var part = new Part(drawing, Vector.Zero); + plate.Parts.Add(part); + nest.Plates.Add(plate); + return (nest, part); + } + + private static (Nest Nest, Part Part) PlannedDoorNest() + { + var (nest, part) = DoorNest(); + var proposal = CuttingPlanBatch.Capture([nest.Plates[0]], ExplicitContourTests.Parameters(), false).Plan(); + Assert.Equal(CuttingCommitStatus.Applied, proposal.Apply(acceptWarnings: false).Status); + Assert.True(DisplayPerimeterTail.HasTail(part.Program)); + return (nest, part); + } + + [Theory] + [MemberData(nameof(Posts))] + public void PlannedDoor_PostsExactlyItsCutsAndNeverTheOutline(string post) + { + var (planned, _) = PlannedDoorNest(); + var (stripped, part) = PlannedDoorNest(); + // The same applied plan with its tail removed by hand: what the machine may run. + Assert.True(part.RestoreLeadInProgram(DisplayPerimeterTail.WithoutTail(part.Program), locked: true)); + + Assert.Equal(Output(post, stripped), Output(post, planned)); + } + + [Theory] + [MemberData(nameof(Posts))] + public void UnplannedDoor_IsRefusedByEveryPost(string post) + { + var (nest, _) = DoorNest(); + + var error = Assert.Throws(() => Output(post, nest)); + + Assert.Contains("display perimeter", error.Message); + Assert.Contains("Plan Cutting", error.Message); + } + + [Fact] + public void UnplannedDoor_PrePostReviewNamesTheDisplayPerimeter() + { + var (nest, _) = DoorNest(); + + var report = PostVerificationAnalyzer.Analyze(nest); + + Assert.Contains(report.Findings, f => f.Message.Contains("display perimeter", StringComparison.OrdinalIgnoreCase)); + Assert.False(report.CanPost(false)); + } + + [Fact] + public void PlannedDoor_PrePostReviewIsClean() + { + var (nest, _) = PlannedDoorNest(); + + var report = PostVerificationAnalyzer.Analyze(nest); + + Assert.DoesNotContain(report.Findings, f => f.Message.Contains("display perimeter", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs index ac7275e4..299713ba 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs @@ -135,7 +135,7 @@ public sealed class CincinnatiPartSubprogramWriter if (part.BaseDrawing.IsCutOff) continue; - var pgm = PostedCopy(part.Program); + var pgm = PostedCopy(FeatureUtils.PostedProgram(part)); var bbox = pgm.BoundingBox(); pgm.Offset(-bbox.Location.X, -bbox.Location.Y); @@ -222,7 +222,7 @@ public sealed class CincinnatiPartSubprogramWriter foreach (var part in plate.Parts) { if (!part.BaseDrawing.IsCutOff) - Register(part.Program); + Register(FeatureUtils.PostedProgram(part)); } } diff --git a/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs b/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs index 516a389c..db452bf4 100644 --- a/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs +++ b/Posts/OpenNest.Posts.Cincinnati/FeatureUtils.cs @@ -80,9 +80,16 @@ public static class FeatureUtils /// Splits a part's program into features by rapids, classifies each as etch or cut, /// and orders etch features before cut features. The part's program is not modified. /// + /// + /// The part program this post may write: a display perimeter's never-cut tail removed, and an + /// unplanned display perimeter refused (). + /// + public static Program PostedProgram(Part part) => + DisplayPerimeterTail.ForPosting(part.Program, $"Part '{part.BaseDrawing?.Name}'"); + public static List<(List codes, bool isEtch)> SplitAndClassify(Part part) { - var codes = RemoveSuppressed(AbsoluteCodes(part.Program)); + var codes = RemoveSuppressed(AbsoluteCodes(PostedProgram(part))); InsertLeadingRapid(codes); return ClassifyAndOrder(SplitByRapids(codes)); diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberContourExtractor.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberContourExtractor.cs index b931179f..7523e3d8 100644 --- a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberContourExtractor.cs +++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberContourExtractor.cs @@ -52,7 +52,9 @@ namespace OpenNest.Posts.CincinnatiCIFiber throw new ArgumentNullException(nameof(part)); var contours = new List(); - Flatten(part.Program, part.Location, Vector.Zero, contours); + // A display perimeter's tail is never cut; an unplanned one is refused. + var program = DisplayPerimeterTail.ForPosting(part.Program, $"Part '{part.BaseDrawing?.Name}'"); + Flatten(program, part.Location, Vector.Zero, contours); return contours; } diff --git a/Posts/OpenNest.Posts.GravographIS/NestPolylineExtractor.cs b/Posts/OpenNest.Posts.GravographIS/NestPolylineExtractor.cs index 577eec35..24074975 100644 --- a/Posts/OpenNest.Posts.GravographIS/NestPolylineExtractor.cs +++ b/Posts/OpenNest.Posts.GravographIS/NestPolylineExtractor.cs @@ -94,6 +94,8 @@ namespace OpenNest.Posts.GravographIS var program = part.Program; if (program == null) return; + // A display perimeter's tail is never cut; an unplanned one is refused. + program = DisplayPerimeterTail.ForPosting(program, $"Part '{part.BaseDrawing?.Name}'"); // The walk below treats Motion.EndPoint as absolute. Convert a working // copy to absolute mode so G91 programs (the form OpenNest's UI writes)