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 d069eacb..8b3784de 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 75610a9e..beb5500b 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.