feat(post): declare placed order for CL nests that post in it

The Cincinnati CL post always drew an "incomplete final rapid sequence"
warning because it never declared IPostVerificationSupport. It writes etch
features before cuts, inserts or drops rapids, and with part sub-programs
rapids to each part's corner, so it cannot declare it unconditionally.

IPostVerificationSupport gains a nest-aware PlacedProgramOrderChange(nest)
(null = preserved; otherwise the reason, shown in the warning). The CL post
answers it from its own writer: part sub-programs and smart rapids off, and
every part's and hole's posted motions exactly equal to its placed ones.
Plan Cutting cuts scribes first, so planned nests qualify. CNC output is
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-11-02 17:21:25 -05:00
1 parent ceddd8cf93
commit 72039a238b
9 files changed
+392 -26

No files matched your search

@@ -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);
}
+10
View File
@@ -9,5 +9,15 @@ namespace OpenNest;
/// </summary>
public interface IPostVerificationSupport
{
/// <summary>True when the post preserves the placed order for every nest.</summary>
bool PreservesPlacedProgramOrder { get; }
/// <summary>
/// Why posting <paramref name="nest"/> 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.
/// </summary>
string PlacedProgramOrderChange(Nest nest) =>
PreservesPlacedProgramOrder ? null : "does not declare that it preserves placed part/contour order and pierce positions";
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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<Vector> Pierces, List<(Vector From, Vector To)> Cuts) Checked(Nest nest)
{
var pierces = new List<Vector>();
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<Vector> expected, List<Vector> 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]}");
}
}
@@ -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]
@@ -163,6 +163,17 @@ public sealed class CincinnatiPartSubprogramWriter
return (mapping, entries);
}
/// <summary>
/// The absolute copy a hole sub-program is written from: suppressed moves left uncut and
/// a leading rapid ensured. <see cref="Write"/> then puts its etch features first.
/// </summary>
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))
@@ -0,0 +1,116 @@
using System;
using System.Collections.Generic;
using System.Linq;
using OpenNest.CNC;
using OpenNest.Geometry;
namespace OpenNest.Posts.Cincinnati;
/// <summary>
/// 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.
/// </summary>
internal static class CincinnatiPlacedOrder
{
/// <summary>Why the posted motion sequence differs from the placed one, or null when it equals it.</summary>
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<ICode> placed;
List<(List<ICode> 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<SubProgramCall>())
{
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<ICode> placed, List<(List<ICode> 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<ICode> placed, IEnumerable<ICode> 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;
}
@@ -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";
/// <summary>Etch-first writing and part sub-programs can change it; see <see cref="PlacedProgramOrderChange"/>.</summary>
public bool PreservesPlacedProgramOrder => false;
/// <summary>
/// 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.
/// </summary>
public string PlacedProgramOrderChange(Nest nest) => CincinnatiPlacedOrder.Change(nest, Config);
public CincinnatiPostConfig Config { get; private set; }
object IConfigurablePostProcessor.Config => Config;
+15
View File
@@ -60,6 +60,21 @@ Sub-program bodies start with a `:<subNum>` 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`
+15 -6
View File
@@ -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.