diff --git a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
index 6ced4fe4..10d8989c 100644
--- a/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
+++ b/OpenNest.Core/Diagnostics/PostVerificationAnalyzer.cs
@@ -19,12 +19,15 @@ public static class PostVerificationAnalyzer
CancellationToken cancellationToken = default)
{
var report = Analyze(nest, cancellationToken);
- if (postProcessor is IPostVerificationSupport { PreservesPlacedProgramOrder: true })
+ var change = postProcessor is IPostVerificationSupport support
+ ? support.PlacedProgramOrderChange(nest)
+ : "does not declare that it preserves placed part/contour order and pierce positions";
+ if (change == null)
return report;
var findings = report.Findings.ToList();
findings.Add(new(PostVerificationKind.Incomplete, 0, null, null,
- $"Post '{postProcessor?.Name ?? "unknown"}' does not declare that it preserves placed part/contour order " +
- "and pierce positions. Nest-level checks ran, but the final rapid sequence requires manual review."));
+ $"Post '{postProcessor?.Name ?? "unknown"}' {change}. " +
+ "Nest-level checks ran, but the final rapid sequence requires manual review."));
return new PostVerificationReport(findings);
}
diff --git a/OpenNest.Core/IPostVerificationSupport.cs b/OpenNest.Core/IPostVerificationSupport.cs
index 8e21d74f..5e117b09 100644
--- a/OpenNest.Core/IPostVerificationSupport.cs
+++ b/OpenNest.Core/IPostVerificationSupport.cs
@@ -9,5 +9,15 @@ namespace OpenNest;
///
public interface IPostVerificationSupport
{
+ /// True when the post preserves the placed order for every nest.
bool PreservesPlacedProgramOrder { get; }
+
+ ///
+ /// Why posting with the current settings would not move in the
+ /// placed order, or null when it would. A post whose order depends on the nest or its
+ /// settings overrides this; it must return null only when the posted motion sequence
+ /// provably equals the placed one.
+ ///
+ string PlacedProgramOrderChange(Nest nest) =>
+ PreservesPlacedProgramOrder ? null : "does not declare that it preserves placed part/contour order and pierce positions";
}
diff --git a/OpenNest.Tests/Cincinnati/CincinnatiPlacedOrderTests.cs b/OpenNest.Tests/Cincinnati/CincinnatiPlacedOrderTests.cs
new file mode 100644
index 00000000..3fe41113
--- /dev/null
+++ b/OpenNest.Tests/Cincinnati/CincinnatiPlacedOrderTests.cs
@@ -0,0 +1,186 @@
+using OpenNest.CNC;
+using OpenNest.CNC.CuttingPlanning;
+using OpenNest.Diagnostics;
+using OpenNest.Geometry;
+using OpenNest.Posts.Cincinnati;
+
+namespace OpenNest.Tests.Cincinnati;
+
+///
+/// The CL post declares that it preserves the placed order only for a nest whose posted
+/// pierces and cuts replay exactly as the pre-post check read them, and withholds it
+/// whenever the post would change them.
+///
+public class CincinnatiPlacedOrderTests
+{
+ [Fact]
+ public void ScribesFirst_PostsTheCheckedSequence_AndDeclaresIt()
+ {
+ var nest = Nest(scribeFirst: true);
+ var post = new CincinnatiPostProcessor(CincinnatiReplay.Config(partSubprograms: false));
+
+ Assert.Null(post.PlacedProgramOrderChange(nest));
+ Assert.Equal(
+ PostVerificationAnalyzer.Analyze(nest).Findings.Select(f => f.Message),
+ PostVerificationAnalyzer.AnalyzeForPost(nest, post).Findings.Select(f => f.Message));
+
+ var (pierces, cuts) = Checked(nest);
+ Assert.Equal(4, pierces.Count); // scribe, hole, perimeter, square
+ var run = CincinnatiReplay.Run(CincinnatiReplay.Post(nest, partSubprograms: false));
+ AssertSame(pierces, run.PierceLog.Select(p => p.Point).ToList());
+ AssertSame(cuts, run.Cuts);
+ }
+
+ [Fact]
+ public void ScribeAfterACut_WithholdsTheDeclaration_BecauseThePostCutsItFirst()
+ {
+ var nest = Nest(scribeFirst: false);
+ var post = new CincinnatiPostProcessor(CincinnatiReplay.Config(partSubprograms: false));
+
+ var change = post.PlacedProgramOrderChange(nest);
+ Assert.Equal("writes the etch features of part 1 on plate 1 before the cuts placed ahead of them", change);
+ var report = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
+ Assert.Contains(report.Findings, f => f.Kind == PostVerificationKind.Incomplete && f.Message.Contains(change));
+ Assert.False(report.CanPost(false));
+
+ // The withheld declaration is real: the posted pierces differ from the checked ones.
+ var (pierces, _) = Checked(nest);
+ var run = CincinnatiReplay.Run(CincinnatiReplay.Post(nest, partSubprograms: false));
+ Assert.Equal(pierces.Count, run.PierceLog.Count);
+ Assert.True(CincinnatiReplay.Near(new Vector(3, 3), run.PierceLog[0].Point));
+ Assert.False(CincinnatiReplay.Near(pierces[0], run.PierceLog[0].Point));
+ }
+
+ [Fact]
+ public void PartSubprograms_WithholdTheDeclaration()
+ {
+ var post = new CincinnatiPostProcessor(CincinnatiReplay.Config(partSubprograms: true));
+
+ Assert.Contains("part sub-programs", post.PlacedProgramOrderChange(Nest(scribeFirst: true)));
+ }
+
+ [Fact]
+ public void SmartRapids_WithholdTheDeclaration()
+ {
+ var config = CincinnatiReplay.Config(partSubprograms: false);
+ config.UseSmartRapids = true;
+
+ Assert.Contains("smart rapids", new CincinnatiPostProcessor(config).PlacedProgramOrderChange(Nest(scribeFirst: true)));
+ }
+
+ [Fact]
+ public void AContourWithoutALeadingRapid_WithholdsTheDeclaration()
+ {
+ // The post pierces it at its return point instead of wherever the tool already is.
+ var program = new Program();
+ program.LineTo(4, 0);
+ program.LineTo(4, 4);
+ program.LineTo(0, 4);
+ program.LineTo(0, 0);
+ var nest = new Nest();
+ nest.CreatePlate().Parts.Add(new Part(new Drawing("square", program), new Vector(2, 2)));
+ var post = new CincinnatiPostProcessor(CincinnatiReplay.Config(partSubprograms: false));
+
+ Assert.Equal("adds or drops rapids in part 1 on plate 1", post.PlacedProgramOrderChange(nest));
+ }
+
+ // A 20x20 plate part at (1, 1) with a scribe, a hole cut through a hole call and its
+ // perimeter, then a 4x4 square at (30, 1). The scribe comes first or after the hole.
+ private static Nest Nest(bool scribeFirst)
+ {
+ var clean = new Program();
+ clean.MoveTo(2, 2);
+ clean.Codes.Add(new LinearMove(6, 2) { Layer = LayerType.Scribe });
+ clean.MoveTo(13, 10);
+ clean.ArcTo(13, 10, 10, 10, RotationType.CCW);
+ clean.MoveTo(20, 10);
+ Outline(clean, 20);
+
+ var hole = new Program();
+ hole.MoveTo(0, 0);
+ hole.Codes.Add(new LinearMove(3, 0) { Layer = LayerType.Leadin });
+ hole.Codes.Add(new ArcMove(new Vector(3, 0), Vector.Zero, RotationType.CCW));
+ var placed = new Program();
+ if (scribeFirst)
+ Scribe(placed);
+ placed.Codes.Add(new SubProgramCall { Id = 1, Program = hole, Offset = new Vector(10, 10) });
+ if (!scribeFirst)
+ Scribe(placed);
+ placed.MoveTo(20.5, 10);
+ placed.Codes.Add(new LinearMove(20, 10) { Layer = LayerType.Leadin });
+ Outline(placed, 20);
+
+ var plate = new Part(new Drawing("plate", clean), new Vector(1, 1));
+ Assert.True(plate.RestoreLeadInProgram(placed, false));
+
+ var square = new Program();
+ square.MoveTo(4, 2);
+ Outline(square, 4);
+ var leaded = new Program();
+ leaded.MoveTo(4.5, 2);
+ leaded.Codes.Add(new LinearMove(4, 2) { Layer = LayerType.Leadin });
+ Outline(leaded, 4);
+ var second = new Part(new Drawing("square", square), new Vector(30, 1));
+ Assert.True(second.RestoreLeadInProgram(leaded, false));
+
+ var nest = new Nest();
+ var sheet = nest.CreatePlate();
+ sheet.Size = new Size(60, 120);
+ sheet.Parts.Add(plate);
+ sheet.Parts.Add(second);
+ return nest;
+
+ static void Scribe(Program program)
+ {
+ program.MoveTo(2, 2);
+ program.Codes.Add(new LinearMove(6, 2) { Layer = LayerType.Scribe });
+ }
+
+ // A size x size square cut counterclockwise from its right-edge midpoint.
+ static void Outline(Program program, double size)
+ {
+ program.LineTo(size, size);
+ program.LineTo(0, size);
+ program.LineTo(0, 0);
+ program.LineTo(size, 0);
+ program.LineTo(size, size / 2);
+ }
+ }
+
+ // The pierces and cuts the pre-post check reads, in plate order and plate coordinates.
+ private static (List Pierces, List<(Vector From, Vector To)> Cuts) Checked(Nest nest)
+ {
+ var pierces = new List();
+ var cuts = new List<(Vector, Vector)>();
+ Vector? position = Vector.Zero;
+ foreach (var part in nest.Plates[0].Parts)
+ {
+ var moves = ExecutionMotionReader.Read(part.Program, part.Location, position, default).Motions;
+ for (var i = 0; i < moves.Count; i++)
+ {
+ if (!moves[i].Rapid)
+ cuts.Add((moves[i].Start!.Value, moves[i].End));
+ else if (i + 1 < moves.Count && !moves[i + 1].Rapid)
+ pierces.Add(moves[i].End);
+ }
+ position = moves[^1].End;
+ }
+ return (pierces, cuts);
+ }
+
+ private static void AssertSame(List expected, List actual)
+ {
+ Assert.Equal(expected.Count, actual.Count);
+ for (var i = 0; i < expected.Count; i++)
+ Assert.True(CincinnatiReplay.Near(expected[i], actual[i]), $"Pierce {i}: {expected[i]} vs {actual[i]}");
+ }
+
+ private static void AssertSame(List<(Vector From, Vector To)> expected, List<(Vector From, Vector To)> actual)
+ {
+ Assert.Equal(expected.Count, actual.Count);
+ for (var i = 0; i < expected.Count; i++)
+ Assert.True(
+ CincinnatiReplay.Near(expected[i].From, actual[i].From) && CincinnatiReplay.Near(expected[i].To, actual[i].To),
+ $"Cut {i}: {expected[i]} vs {actual[i]}");
+ }
+}
diff --git a/OpenNest.Tests/Diagnostics/PostVerificationPostContractTests.cs b/OpenNest.Tests/Diagnostics/PostVerificationPostContractTests.cs
index 318e73d7..6aa61ce4 100644
--- a/OpenNest.Tests/Diagnostics/PostVerificationPostContractTests.cs
+++ b/OpenNest.Tests/Diagnostics/PostVerificationPostContractTests.cs
@@ -10,23 +10,30 @@ namespace OpenNest.Tests.Diagnostics;
public class PostVerificationPostContractTests
{
[Fact]
- public void ReorderingPostsRequireAcknowledgmentEvenWhenNestChecksAreClear()
+ public void ReorderingPostRequiresAcknowledgmentEvenWhenNestChecksAreClear()
{
var nest = ClearNest();
Assert.False(PostVerificationAnalyzer.Analyze(nest).HasWarnings);
- IPostProcessor[] posts =
- [
- new CincinnatiPostProcessor(new CincinnatiPostConfig()),
- new GravographISPostProcessor(new GravographISPostConfig())
- ];
- foreach (var post in posts)
- {
- var report = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
- Assert.Contains(report.Findings, finding => finding.Kind == PostVerificationKind.Incomplete);
- Assert.False(report.CanPost(false));
- Assert.True(report.CanPost(true));
- Assert.Contains(post.Name, report.ToDisplayText());
- }
+ var post = new GravographISPostProcessor(new GravographISPostConfig());
+ var report = PostVerificationAnalyzer.AnalyzeForPost(nest, post);
+ Assert.Contains(report.Findings, finding => finding.Kind == PostVerificationKind.Incomplete);
+ Assert.False(report.CanPost(false));
+ Assert.True(report.CanPost(true));
+ Assert.Contains(post.Name, report.ToDisplayText());
+ }
+
+ [Fact]
+ public void CincinnatiClDeclaresPlacedOrderOnlyWhenThisNestPostsInIt()
+ {
+ var report = PostVerificationAnalyzer.AnalyzeForPost(ClearNest(),
+ new CincinnatiPostProcessor(new CincinnatiPostConfig()));
+ Assert.Empty(report.Findings);
+ Assert.True(report.CanPost(false));
+
+ var subprograms = PostVerificationAnalyzer.AnalyzeForPost(ClearNest(),
+ new CincinnatiPostProcessor(new CincinnatiPostConfig { UsePartSubprograms = true }));
+ Assert.Contains(subprograms.Findings, finding => finding.Kind == PostVerificationKind.Incomplete);
+ Assert.False(subprograms.CanPost(false));
}
[Fact]
diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs
index a4e8dded..f1f39766 100644
--- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs
+++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs
@@ -163,6 +163,17 @@ public sealed class CincinnatiPartSubprogramWriter
return (mapping, entries);
}
+ ///
+ /// The absolute copy a hole sub-program is written from: suppressed moves left uncut and
+ /// a leading rapid ensured. then puts its etch features first.
+ ///
+ internal static Program PostedHole(Program hole)
+ {
+ var absolute = PostedCopy(hole);
+ EnsureLeadingRapid(absolute);
+ return absolute;
+ }
+
// An absolute copy with suppressed moves left uncut, as the post writes it.
private static Program PostedCopy(Program program)
{
@@ -203,8 +214,7 @@ public sealed class CincinnatiPartSubprogramWriter
if (mapping.ContainsKey(call.Program) || !FeatureUtils.HasCut(call.Program))
continue;
- var absolute = PostedCopy(call.Program);
- EnsureLeadingRapid(absolute);
+ var absolute = PostedHole(call.Program);
var canonical = ProgramToCanonical(absolute);
if (!contentIndex.TryGetValue(canonical, out var subNum))
diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPlacedOrder.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPlacedOrder.cs
new file mode 100644
index 00000000..3063eb48
--- /dev/null
+++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPlacedOrder.cs
@@ -0,0 +1,116 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using OpenNest.CNC;
+using OpenNest.Geometry;
+
+namespace OpenNest.Posts.Cincinnati;
+
+///
+/// Decides whether the post moves through a nest in the placed order the pre-post check
+/// verified (see docs/post-verification.md). The writer keeps plate order, but writes each
+/// part's (and hole's) etch features before its cut features, adds a rapid ahead of a contour
+/// that has none, drops rapids with nothing to cut and, with part sub-programs, rapids to and
+/// from each part's corner. The order is declared preserved only when, for this nest and
+/// these settings, none of that changes a single motion.
+///
+internal static class CincinnatiPlacedOrder
+{
+ /// Why the posted motion sequence differs from the placed one, or null when it equals it.
+ internal static string Change(Nest nest, CincinnatiPostConfig config)
+ {
+ if (config.UsePartSubprograms)
+ return "calls part sub-programs, which add rapids to and from each part's corner";
+ if (config.UseSmartRapids)
+ return "uses smart rapids (G121), whose blended rapids leave the straight line";
+ if (nest?.Plates == null)
+ return null;
+
+ for (var plateIndex = 0; plateIndex < nest.Plates.Count; plateIndex++)
+ {
+ var parts = nest.Plates[plateIndex]?.Parts;
+ if (parts == null)
+ continue;
+ for (var partIndex = 0; partIndex < parts.Count; partIndex++)
+ {
+ var part = parts[partIndex];
+ if (part?.Program == null)
+ continue;
+ var where = $"part {partIndex + 1} on plate {plateIndex + 1}";
+ List placed;
+ List<(List codes, bool isEtch)> posted;
+ try
+ {
+ placed = FeatureUtils.AbsoluteCodes(FeatureUtils.PostedProgram(part));
+ posted = FeatureUtils.SplitAndClassify(part);
+ }
+ catch (InvalidOperationException)
+ {
+ return $"cannot post {where} as placed";
+ }
+
+ var change = ChangeTo(placed, posted, where);
+ if (change != null)
+ return change;
+
+ foreach (var call in placed.OfType())
+ {
+ if (call.Program == null || !FeatureUtils.HasCut(call.Program))
+ continue;
+ var hole = CincinnatiPartSubprogramWriter.PostedHole(call.Program);
+ change = ChangeTo(
+ FeatureUtils.AbsoluteCodes(call.Program),
+ FeatureUtils.ClassifyAndOrder(FeatureUtils.SplitByRapids(hole.Codes)),
+ $"a hole of {where}"
+ );
+ if (change != null)
+ return change;
+ }
+ }
+ }
+ return null;
+ }
+
+ private static string ChangeTo(List placed, List<(List codes, bool isEtch)> posted, string where)
+ {
+ if (SameMotions(placed, posted.SelectMany(feature => feature.codes)))
+ return null;
+ var cutSeen = false;
+ foreach (var feature in FeatureUtils.SplitByRapids(placed))
+ {
+ var isEtch = FeatureUtils.IsEtch(feature);
+ if (isEtch && cutSeen)
+ return $"writes the etch features of {where} before the cuts placed ahead of them";
+ cutSeen |= !isEtch;
+ }
+ return $"adds or drops rapids in {where}";
+ }
+
+ // Motions and hole calls in the same order with exactly the same coordinates.
+ private static bool SameMotions(IEnumerable placed, IEnumerable posted)
+ {
+ var a = placed.Where(code => code is Motion or SubProgramCall).ToList();
+ var b = posted.Where(code => code is Motion or SubProgramCall).ToList();
+ if (a.Count != b.Count)
+ return false;
+ for (var i = 0; i < a.Count; i++)
+ {
+ if (!Same(a[i], b[i]))
+ return false;
+ }
+ return true;
+ }
+
+ private static bool Same(ICode a, ICode b) =>
+ (a, b) switch
+ {
+ (SubProgramCall x, SubProgramCall y) => ReferenceEquals(x.Program, y.Program) && Exact(x.Offset, y.Offset),
+ (ArcMove x, ArcMove y) => Exact(x.EndPoint, y.EndPoint)
+ && Exact(x.CenterPoint, y.CenterPoint)
+ && x.Rotation == y.Rotation,
+ (Motion x, Motion y) => x.GetType() == y.GetType() && Exact(x.EndPoint, y.EndPoint),
+ _ => false,
+ };
+
+ private static bool Exact(Vector a, Vector b) => a.X == b.X && a.Y == b.Y;
+}
diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs
index 91d039e2..beb61c7f 100644
--- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs
+++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs
@@ -13,7 +13,8 @@ namespace OpenNest.Posts.Cincinnati
public sealed class CincinnatiPostProcessor
: IJsonConfigPostProcessor,
IPostProcessorNestAware,
- IMaterialProvidingPostProcessor
+ IMaterialProvidingPostProcessor,
+ IPostVerificationSupport
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -25,6 +26,15 @@ namespace OpenNest.Posts.Cincinnati
public string Author => "OpenNest";
public string Description => "Cincinnati CL-707/CL-800/CL-900/CL-940/CLX family";
+ /// Etch-first writing and part sub-programs can change it; see .
+ public bool PreservesPlacedProgramOrder => false;
+
+ ///
+ /// Null when this nest posts with the current settings in exactly its placed motion order,
+ /// which Plan Cutting's scribes-first programs do; otherwise what the post changes.
+ ///
+ public string PlacedProgramOrderChange(Nest nest) => CincinnatiPlacedOrder.Change(nest, Config);
+
public CincinnatiPostConfig Config { get; private set; }
object IConfigurablePostProcessor.Config => Config;
diff --git a/docs/cincinnati-post-output.md b/docs/cincinnati-post-output.md
index 8a779a78..55a660c7 100644
--- a/docs/cincinnati-post-output.md
+++ b/docs/cincinnati-post-output.md
@@ -60,6 +60,21 @@ Sub-program bodies start with a `:` label and end with `M99`.
The post reads part programs through absolute-mode copies and never changes the
nest: an incremental part program stays incremental after posting.
+### Order and pre-post verification
+
+Within a part (and within each hole sub-program) the post writes etch features
+before cut features, keeping each group in program order (`FeatureUtils.ClassifyAndOrder`).
+It also inserts a rapid to a contour's return point when the contour opens without
+one, and drops rapids with nothing to cut. Part sub-programs add a rapid to each
+part's toolpath corner and back, and smart rapids blend rapids off the straight line.
+The post declares that it preserves the placed order to
+[pre-post verification](post-verification.md#selected-post-and-cut-order)
+(`CincinnatiPlacedOrder`) only when, for the nest and settings being posted, none of
+these changes a motion: part sub-programs and smart rapids off, and each part's and
+hole's posted motions exactly equal to its placed ones. Plan Cutting already cuts a
+part's scribes first, so planned nests qualify; otherwise posting warns that the
+final rapid sequence is unverified. Output is the same either way.
+
### Sheet header
Each sheet sub-program re-anchors the work frame with `G92 X#5021 Y#5022`
diff --git a/docs/post-verification.md b/docs/post-verification.md
index 5b4b3e1c..3373930a 100644
--- a/docs/post-verification.md
+++ b/docs/post-verification.md
@@ -198,11 +198,17 @@ the gate is installed at OpenNest's desktop and console posting entry points.
Cincinnati CI Fiber preserves the placed part/contour order and pierce positions
used by this check. Cincinnati CL keeps placed part order, including cut-offs, but
-puts etch features before cut features and can add subprogram routing moves;
-Gravograph builds and optimizes separate tool passes. Those two posts, and unknown
-plugins, therefore show an additional **incomplete final rapid sequence** warning
-requiring acknowledgment even if the nest-level checks have no findings. This
-change does not silently alter their existing CNC output order.
+writes each part's (and hole's) etch features before its cut features, adds a rapid
+ahead of a contour that opens without one, drops rapids with nothing to cut, and with
+**Use part sub-programs** rapids to and from each part's corner. It declares the
+placed order for a nest only when none of that changes a single motion with the
+settings being posted: part sub-programs and smart rapids off, and every part's and
+hole's motions, compared exactly, unchanged by its writer. Plan Cutting's programs
+cut a part's scribes first, so a planned nest qualifies. Otherwise the warning names
+the first part it changes, and the CNC output order is left as it was.
+Gravograph builds and optimizes separate tool passes. It, a CL nest that does not
+qualify, and unknown plugins show an additional **incomplete final rapid sequence**
+warning requiring acknowledgment even if the nest-level checks have no findings.
## Limits and maintainer contract
@@ -218,7 +224,10 @@ its report exposes findings, display text, and `CanPost(risksAcknowledged)`. Use
`AnalyzeForPost` at posting entry points so unknown post ordering is not silently
certified. `IPostVerificationSupport.PreservesPlacedProgramOrder` is an opt-in
contract, not a machine-safety claim; only declare it after verifying the writer's
-part order, contour order, and pierce positions. The WinForms dialog renders the
+part order, contour order, and pierce positions. A post whose order depends on the
+nest or its settings overrides `PlacedProgramOrderChange(nest)` instead, returning
+null only when the posted motion sequence provably equals the placed one and
+otherwise what it changes; the analyzer shows that reason in the warning. The WinForms dialog renders the
report and enforces per-attempt consent. New callers
must keep inputs stable until analysis completes, honor cancellation, and never
treat incomplete analysis as a clear result. Keep analysis out of paint handlers.