diff --git a/OpenNest.Tests/Cincinnati/HoleCallOutputCharacterizationTests.cs b/OpenNest.Tests/Cincinnati/HoleCallOutputCharacterizationTests.cs new file mode 100644 index 0000000..ae0c78a --- /dev/null +++ b/OpenNest.Tests/Cincinnati/HoleCallOutputCharacterizationTests.cs @@ -0,0 +1,259 @@ +using OpenNest.CNC; +using OpenNest.Geometry; +using OpenNest.Posts.Cincinnati; + +namespace OpenNest.Tests.Cincinnati; + +public class HoleCallOutputCharacterizationTests +{ + [Theory] + [InlineData("part", true)] + [InlineData("part", false)] + [InlineData("inline-sheet", true)] + [InlineData("inline-sheet", false)] + [InlineData("grouped-sheet", true)] + [InlineData("grouped-sheet", false)] + public void SingleHole_NullMap_FirstAndLastFeature_HasExactOutput(string route, bool lineNumbers) + { + var config = CreateConfig(lineNumbers); + var program = CreateProgram(CreateCall(7, -12.34567, 6.78901)); + var write = CreateWriter(route, config, null); + using var output = new StringWriter { NewLine = "\n" }; + + write(output, program); + + var body = Lines( + lineNumbers ? "N73 G52 X-12.35 Y6.79" : "G52 X-12.35 Y6.79", + "M98 P7", + "G52 X0 Y0" + ); + Assert.Equal(WrapOutput(route, body), output.ToString()); + } + + [Theory] + [InlineData("part", true)] + [InlineData("part", false)] + [InlineData("inline-sheet", true)] + [InlineData("inline-sheet", false)] + [InlineData("grouped-sheet", true)] + [InlineData("grouped-sheet", false)] + public void MultipleHoles_LiveMap_FirstAndLaterIndices_HaveExactOutput(string route, bool lineNumbers) + { + var config = CreateConfig(lineNumbers); + config.InteriorM47 = M47Mode.None; + var mapping = new Dictionary { [7] = 900 }; + var write = CreateWriter(route, config, mapping); + // The caller-owned map is consulted when writing, not copied at construction. + mapping[7] = 901; + mapping[9] = 0; + var program = CreateProgram( + CreateCall(7, -12.34567, 6.78901), + CreateCall(8, 0.00499, -0.006), + CreateCall(9, 1.1, -2.2) + ); + using var output = new StringWriter { NewLine = "\n" }; + + write(output, program); + + var body = Lines( + lineNumbers ? "N73 G52 X-12.35 Y6.79" : "G52 X-12.35 Y6.79", + "M98 P901", + "G52 X0 Y0", + "M47", + lineNumbers ? "N1002 G52 X0 Y-0.01" : "G52 X0 Y-0.01", + "M98 P8", + "G52 X0 Y0", + "M47", + lineNumbers ? "N1003 G52 X1.1 Y-2.2" : "G52 X1.1 Y-2.2", + "M98 P0", + "G52 X0 Y0" + ); + Assert.Equal(WrapOutput(route, body), output.ToString()); + } + + [Theory] + [InlineData("part", true)] + [InlineData("part", false)] + [InlineData("inline-sheet", true)] + [InlineData("inline-sheet", false)] + [InlineData("grouped-sheet", true)] + [InlineData("grouped-sheet", false)] + public void HoleBetweenOrdinaryFeatures_PreservesFeatureSequence(string route, bool lineNumbers) + { + var config = CreateConfig(lineNumbers); + var program = CreateProgram( + new RapidMove(1, 1), + new LinearMove(2, 1), + new LinearMove(1, 1), + CreateCall(7, -12.34567, 6.78901), + new RapidMove(3, 3), + new LinearMove(4, 3), + new LinearMove(3, 3) + ); + var write = CreateWriter(route, config, new Dictionary()); + using var output = new StringWriter { NewLine = "\n" }; + + write(output, program); + + var body = Lines(lineNumbers ? "N73 G0 X1 Y1" : "G0 X1 Y1"); + if (route != "part") + body += Lines("( PART: Sample )"); + body += Lines( + "G84", + "G1 X2 Y1 F#148", + "G1 X1 Y1", + "M35", + "M47", + lineNumbers ? "N1002 G52 X-12.35 Y6.79" : "G52 X-12.35 Y6.79", + "M98 P7", + "G52 X0 Y0", + "M47", + lineNumbers ? "N1003 G0 X3 Y3" : "G0 X3 Y3", + "G84", + "G1 X4 Y3 F#148", + "G1 X3 Y3", + "M35" + ); + Assert.Equal(WrapOutput(route, body), output.ToString()); + } + + [Theory] + [InlineData("part")] + [InlineData("inline-sheet")] + [InlineData("grouped-sheet")] + public void ConfigChanges_AreLiveExceptConstructionTimePrecision(string route) + { + var config = CreateConfig(false); + var write = CreateWriter(route, config, null); + var program = CreateProgram(CreateCall(7, -12.34567, 6.78901)); + config.UseLineNumbers = true; + config.FeatureLineNumberStart = 87; + config.PostedAccuracy = 5; + using var first = new StringWriter { NewLine = "\n" }; + + write(first, program); + + Assert.Equal( + WrapOutput(route, Lines("N87 G52 X-12.35 Y6.79", "M98 P7", "G52 X0 Y0")), + first.ToString() + ); + config.UseLineNumbers = false; + using var second = new StringWriter { NewLine = "\n" }; + write(second, program); + Assert.Equal( + WrapOutput(route, Lines("G52 X-12.35 Y6.79", "M98 P7", "G52 X0 Y0")), + second.ToString() + ); + } + + [Theory] + [InlineData("part")] + [InlineData("inline-sheet")] + [InlineData("grouped-sheet")] + public void HoleWriteFailure_PropagatesWithoutResetOrLaterFeature(string route) + { + var write = CreateWriter(route, CreateConfig(true), null); + var program = CreateProgram(CreateCall(7, -12.34567, 6.78901), CreateCall(8, 1, 2)); + using var output = new RejectHoleCallWriter(); + + var error = Assert.Throws(() => write(output, program)); + + Assert.Same(output.Failure, error); + Assert.Equal(Header(route) + Lines("N73 G52 X-12.35 Y6.79"), output.ToString()); + } + + private static CincinnatiPostConfig CreateConfig(bool lineNumbers) => new() + { + PostedAccuracy = 2, + FeatureLineNumberStart = 73, + UseLineNumbers = lineNumbers, + PalletExchange = PalletMode.None, + ProcessParameterMode = G89Mode.Explicit, + KerfCompensation = KerfMode.PreApplied, + UseAntiDive = false, + }; + + private static Action CreateWriter( + string route, + CincinnatiPostConfig config, + Dictionary? mapping + ) + { + if (route == "part") + { + var writer = new CincinnatiPartSubprogramWriter(config, mapping); + return (output, program) => writer.Write(output, program, "Sample", 201, "", "", 10); + } + + var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager(), mapping); + Dictionary<(int, long), int>? partMapping = route == "grouped-sheet" ? new() : null; + return (output, program) => + { + var plate = new Plate(48, 96); + plate.Parts.Add(new Part(new Drawing("Sample", program))); + sheetWriter.Write(output, plate, "Nest", 1, 101, "", "", partMapping); + }; + } + + private static Program CreateProgram(params ICode[] codes) + { + var program = new Program(); + program.Codes.AddRange(codes); + foreach (var call in codes.OfType()) + program.SubPrograms[call.Id] = call.Program; + return program; + } + + private static SubProgramCall CreateCall(int id, double x, double y) + { + var hole = new Program(); + hole.Codes.Add(new LinearMove(1, 0) { Layer = LayerType.Leadin }); + hole.Codes.Add(new ArcMove(new Vector(1, 0), Vector.Zero, RotationType.CW)); + hole.Mode = Mode.Incremental; + return new SubProgramCall { Id = id, Program = hole, Offset = new Vector(x, y) }; + } + + // Literal snapshots deliberately do not call the production emitter/formatter. + private static string WrapOutput(string route, string body) => Header(route) + body + + (route == "part" ? Lines("M99 (END OF Sample)") : Lines("M42", "M99 (END OF Nest.001)")); + + private static string Header(string route) + { + if (route == "part") + return Lines("(*****************************************************)", ":201", "( PART: Sample )"); + + return Lines( + "(*****************************************************)", + "( START OF Nest.001 )", + ":101", + "( Layout 1 )", + "( SHEET NAME = 48 X 96 )", + "( Total parts on sheet = 1 )", + "#110=48 (SHEET WIDTH FOR CUTOFFS)", + "#111=96 (SHEET LENGTH FOR CUTOFFS)", + "M42", + "N10000", + "G92 X#5021 Y#5022", + "M98 P100 (Variable Declaration)", + "G90", + "M47", + "GOTO1( Goto Feature )" + ); + } + + private static string Lines(params string[] lines) => string.Join("\n", lines) + "\n"; + + private sealed class RejectHoleCallWriter : StringWriter + { + public readonly IOException Failure = new("Rejected hole call"); + + public RejectHoleCallWriter() => NewLine = "\n"; + + public override void WriteLine(string? value) + { + if (value == "M98 P7") + throw Failure; + base.WriteLine(value); + } + } +} diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiHoleCallWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiHoleCallWriter.cs new file mode 100644 index 0000000..81f8c0e --- /dev/null +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiHoleCallWriter.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; +using OpenNest.CNC; + +namespace OpenNest.Posts.Cincinnati; + +internal static class CincinnatiHoleCallWriter +{ + internal static void Write( + TextWriter w, + SubProgramCall call, + int featureIndex, + bool isLastFeature, + CincinnatiPostConfig config, + CoordinateFormatter fmt, + Dictionary holeSubprograms + ) + { + var postSubNum = + holeSubprograms != null && holeSubprograms.TryGetValue(call.Id, out var num) + ? num + : call.Id; + + var featureNumber = + featureIndex == 0 ? config.FeatureLineNumberStart : 1000 + featureIndex + 1; + + // Shift the local origin to the hole center via G52 (manual §1.52). + // G52 does not move the nozzle: the hole-local sub-program's first rapid + // goes directly from the previous feature's end to pierce. + // See docs/cincinnati-post-output.md for the full bracket. + var sb = new StringBuilder(); + if (config.UseLineNumbers) + sb.Append($"N{featureNumber} "); + sb.Append($"G52 X{fmt.FormatCoord(call.Offset.X)} Y{fmt.FormatCoord(call.Offset.Y)}"); + w.WriteLine(sb.ToString()); + + w.WriteLine($"M98 P{postSubNum}"); + + // Cancel the local shift (manual §1.52). + w.WriteLine("G52 X0 Y0"); + + if (!isLastFeature) + w.WriteLine("M47"); + } +} diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs index 7b40c9f..98c2e70 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiPartSubprogramWriter.cs @@ -63,7 +63,9 @@ public sealed class CincinnatiPartSubprogramWriter // SubProgramCall features are emitted as M98 hole calls if (codes.Count == 1 && codes[0] is SubProgramCall holeCall) { - WriteHoleSubprogramCall(w, holeCall, i, isLastFeature); + CincinnatiHoleCallWriter.Write( + w, holeCall, i, isLastFeature, _config, _fmt, _holeSubprograms + ); continue; } @@ -91,35 +93,6 @@ public sealed class CincinnatiPartSubprogramWriter w.WriteLine($"M99 (END OF {drawingName})"); } - private void WriteHoleSubprogramCall( - TextWriter w, - SubProgramCall call, - int featureIndex, - bool isLastFeature - ) - { - var postSubNum = - _holeSubprograms != null && _holeSubprograms.TryGetValue(call.Id, out var num) - ? num - : call.Id; - - var featureNumber = - featureIndex == 0 ? _config.FeatureLineNumberStart : 1000 + featureIndex + 1; - - var sb = new StringBuilder(); - if (_config.UseLineNumbers) - sb.Append($"N{featureNumber} "); - sb.Append($"G52 X{_fmt.FormatCoord(call.Offset.X)} Y{_fmt.FormatCoord(call.Offset.Y)}"); - w.WriteLine(sb.ToString()); - - w.WriteLine($"M98 P{postSubNum}"); - - w.WriteLine("G52 X0 Y0"); - - if (!isLastFeature) - w.WriteLine("M47"); - } - /// /// If the program has no leading rapid, inserts a synthetic rapid at the /// last motion endpoint (the contour return point). This ensures the feature diff --git a/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs b/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs index f099313..872b456 100644 --- a/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs +++ b/Posts/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs @@ -184,7 +184,9 @@ public sealed class CincinnatiSheetWriter // SubProgramCall features are emitted as M98 hole calls if (codes.Count == 1 && codes[0] is SubProgramCall holeCall) { - WriteHoleSubprogramCall(w, holeCall, featureIndex, isLastFeature); + CincinnatiHoleCallWriter.Write( + w, holeCall, featureIndex, isLastFeature, _config, _fmt, _holeSubprograms + ); featureIndex++; lastPartName = partName; continue; @@ -268,42 +270,6 @@ public sealed class CincinnatiSheetWriter w.WriteLine("M47"); } - private void WriteHoleSubprogramCall( - TextWriter w, - SubProgramCall call, - int featureIndex, - bool isLastFeature - ) - { - var postSubNum = - _holeSubprograms != null && _holeSubprograms.TryGetValue(call.Id, out var num) - ? num - : call.Id; - - var featureNumber = - featureIndex == 0 ? _config.FeatureLineNumberStart : 1000 + featureIndex + 1; - - // Shift the local origin to the hole center via G52 (manual §1.52). - // G52 does not move the nozzle, so the sub-program's first rapid - // (the lead-in to the pierce point) takes the tool straight from the - // previous feature's end to pierce. The hole sub-program is authored - // in hole-local coordinates and resolves to `hole + local` under the - // shift. See docs/cincinnati-post-output.md for the full bracket. - var sb = new StringBuilder(); - if (_config.UseLineNumbers) - sb.Append($"N{featureNumber} "); - sb.Append($"G52 X{_fmt.FormatCoord(call.Offset.X)} Y{_fmt.FormatCoord(call.Offset.Y)}"); - w.WriteLine(sb.ToString()); - - w.WriteLine($"M98 P{postSubNum}"); - - // Cancel the local shift (manual §1.52). - w.WriteLine("G52 X0 Y0"); - - if (!isLastFeature) - w.WriteLine("M47"); - } - private void WritePartsInline( TextWriter w, List allParts, @@ -337,7 +303,9 @@ public sealed class CincinnatiSheetWriter // SubProgramCall features are emitted as M98 hole calls if (codes.Count == 1 && codes[0] is SubProgramCall holeCall) { - WriteHoleSubprogramCall(w, holeCall, i, isLastFeature); + CincinnatiHoleCallWriter.Write( + w, holeCall, i, isLastFeature, _config, _fmt, _holeSubprograms + ); lastPartName = partName; continue; }