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)