fix(posts): never post a display perimeter

The posts treat every non-lead feed move as a cut, so an applied blank's
display tail, or the outline of a blank posted without a plan, would have
been cut along the blank's edge. The pre-post review counted that outline
as an ordinary cutting contour.

Cincinnati, CI Fiber and the engraver now read part programs through
DisplayPerimeterTail.ForPosting: an applied program posts exactly what the
same program posts with its tail removed by hand, and an unplanned display
perimeter is refused with a message that names the part and points to Plan
Cutting. The pre-post review reports the unplanned outline by name.
This commit is contained in:
aj committed 2026-10-22 20:15:58 -04:00
1 parent 684207039a
commit 6201065034
6 files changed
+122 -4

No files matched your search

@@ -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))
@@ -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;
/// <summary>No post may cut a supplied blank's display perimeter, planned or not.</summary>
public class DisplayPerimeterPostTests
{
public static IEnumerable<object[]> 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<InvalidOperationException>(() => 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));
}
}
@@ -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));
}
}
@@ -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.
/// </summary>
/// <summary>
/// The part program this post may write: a display perimeter's never-cut tail removed, and an
/// unplanned display perimeter refused (<see cref="DisplayPerimeterTail.ForPosting"/>).
/// </summary>
public static Program PostedProgram(Part part) =>
DisplayPerimeterTail.ForPosting(part.Program, $"Part '{part.BaseDrawing?.Name}'");
public static List<(List<ICode> codes, bool isEtch)> SplitAndClassify(Part part)
{
var codes = RemoveSuppressed(AbsoluteCodes(part.Program));
var codes = RemoveSuppressed(AbsoluteCodes(PostedProgram(part)));
InsertLeadingRapid(codes);
return ClassifyAndOrder(SplitByRapids(codes));
@@ -52,7 +52,9 @@ namespace OpenNest.Posts.CincinnatiCIFiber
throw new ArgumentNullException(nameof(part));
var contours = new List<CIFiberContour>();
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;
}
@@ -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)