From 3d4204db7b243b5c12de46cadadb49c0e805c30d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 30 Mar 2026 09:33:50 -0400 Subject: [PATCH] fix: Cincinnati post processor arc feedrate, G89 spacing, pallet exchange, and preamble - Add radius-based arc feedrate calculation (Variables/Percentages modes) with configurable radius ranges (#123/#124/#125 or inline expressions) - Fix arc distance in SpeedClassifier using actual arc length instead of chord length (full circles previously computed as zero) - Fix G89 P spacing: P now adjacent to filename per CL-707 manual syntax - Add lead-out feedrate support (#129) and arc lead-in feedrate (#127) - Fix pallet exchange: StartAndEnd emits M50 in preamble + last sheet only - Add G121 Smart Rapids emission when UseSmartRapids is enabled - Add G90 absolute mode to main program preamble alongside G20/G21 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CincinnatiFeatureWriter.cs | 43 +++++-- .../CincinnatiPostConfig.cs | 53 ++++++++ .../CincinnatiPostProcessor.cs | 15 ++- .../CincinnatiPreambleWriter.cs | 10 +- .../CincinnatiSheetWriter.cs | 11 +- OpenNest.Posts.Cincinnati/FeatureUtils.cs | 40 +++++- .../CincinnatiFeatureWriterTests.cs | 119 +++++++++++++++++- .../CincinnatiPostProcessorTests.cs | 68 ++++++++++ .../CincinnatiPreambleWriterTests.cs | 71 ++++++++++- .../Cincinnati/CincinnatiSheetWriterTests.cs | 107 +++++++++++++++- 10 files changed, 515 insertions(+), 22 deletions(-) diff --git a/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs b/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs index 1602059..ca5bbde 100644 --- a/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs +++ b/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs @@ -70,7 +70,7 @@ public sealed class CincinnatiFeatureWriter { var speedClass = _speedClassifier.Classify(ctx.CutDistance, ctx.SheetDiagonal); var cutDist = _speedClassifier.FormatCutDist(ctx.CutDistance, ctx.SheetDiagonal); - writer.WriteLine($"G89 P {lib} ({speedClass} {cutDist})"); + writer.WriteLine($"G89 P{lib} ({speedClass} {cutDist})"); } else { @@ -108,7 +108,7 @@ public sealed class CincinnatiFeatureWriter sb.Append($"G1 X{_fmt.FormatCoord(linear.EndPoint.X)} Y{_fmt.FormatCoord(linear.EndPoint.Y)}"); // Feedrate — etch always uses process feedrate - var feedVar = ctx.IsEtch ? "#148" : GetFeedVariable(linear.Layer); + var feedVar = ctx.IsEtch ? "#148" : GetLinearFeedVariable(linear.Layer); if (feedVar != lastFeedVar) { sb.Append($" F{feedVar}"); @@ -138,11 +138,11 @@ public sealed class CincinnatiFeatureWriter var j = arc.CenterPoint.Y - currentPos.Y; sb.Append($" I{_fmt.FormatCoord(i)} J{_fmt.FormatCoord(j)}"); - // Feedrate — etch always uses process feedrate, cut uses layer-based + // Feedrate — etch always uses process feedrate, cut uses layer/radius-based + var radius = currentPos.DistanceTo(arc.CenterPoint); var isFullCircle = IsFullCircle(currentPos, arc.EndPoint); var feedVar = ctx.IsEtch ? "#148" - : isFullCircle ? "[#148*#128]" - : GetFeedVariable(arc.Layer); + : GetArcFeedrate(arc.Layer, radius, isFullCircle); if (feedVar != lastFeedVar) { sb.Append($" F{feedVar}"); @@ -223,16 +223,45 @@ public sealed class CincinnatiFeatureWriter } } - private static string GetFeedVariable(LayerType layer) + private static string GetLinearFeedVariable(LayerType layer) { return layer switch { LayerType.Leadin => "#126", - LayerType.Cut => "#148", + LayerType.Leadout => "#129", _ => "#148" }; } + private string GetArcFeedrate(LayerType layer, double radius, bool isFullCircle) + { + if (layer == LayerType.Leadin) return "#127"; + if (layer == LayerType.Leadout) return "#129"; + if (isFullCircle) return "[#148*#128]"; + return GetArcCutFeedrate(radius); + } + + private string GetArcCutFeedrate(double radius) + { + if (_config.ArcFeedrate == ArcFeedrateMode.None) + return "#148"; + + // Find the smallest range that contains this radius + ArcFeedrateRange best = null; + foreach (var range in _config.ArcFeedrateRanges) + { + if (radius <= range.MaxRadius && (best == null || range.MaxRadius < best.MaxRadius)) + best = range; + } + + if (best == null) + return "#148"; + + return _config.ArcFeedrate == ArcFeedrateMode.Variables + ? $"#{best.VariableNumber}" + : $"[#148*{best.FeedratePercent.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}]"; + } + private static bool IsFullCircle(Vector start, Vector end) { return Tolerance.IsEqualTo(start.X, end.X) && Tolerance.IsEqualTo(start.Y, end.Y); diff --git a/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs b/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs index 34092e6..fc59426 100644 --- a/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs +++ b/OpenNest.Posts.Cincinnati/CincinnatiPostConfig.cs @@ -269,12 +269,35 @@ namespace OpenNest.Posts.Cincinnati /// public double LeadInArcLine2FeedratePercent { get; set; } = 0.5; + /// + /// Gets or sets the feedrate percentage for lead-out moves. + /// Default: 0.5 (50%) + /// + public double LeadOutFeedratePercent { get; set; } = 0.5; + /// /// Gets or sets the feedrate multiplier for circular cuts. /// Default: 0.8 (80%) /// public double CircleFeedrateMultiplier { get; set; } = 0.8; + /// + /// Gets or sets the arc feedrate calculation mode. + /// Default: ArcFeedrateMode.None + /// + public ArcFeedrateMode ArcFeedrate { get; set; } = ArcFeedrateMode.None; + + /// + /// Gets or sets the radius-based arc feedrate ranges. + /// Ranges are matched from smallest MaxRadius to largest. + /// + public List ArcFeedrateRanges { get; set; } = new() + { + new() { MaxRadius = 0.125, FeedratePercent = 0.25, VariableNumber = 123 }, + new() { MaxRadius = 0.750, FeedratePercent = 0.50, VariableNumber = 124 }, + new() { MaxRadius = 4.500, FeedratePercent = 0.80, VariableNumber = 125 } + }; + /// /// Gets or sets the variable number for sheet width. /// Default: 110 @@ -301,4 +324,34 @@ namespace OpenNest.Posts.Cincinnati public string Gas { get; set; } = ""; public string Library { get; set; } = ""; } + + /// + /// Specifies how arc feedrates are calculated based on radius. + /// + public enum ArcFeedrateMode + { + /// No radius-based arc feedrate adjustment (only full circles use multiplier). + None, + + /// Inline percentage expressions: F [#148*pct] based on radius range. + Percentages, + + /// Radius-range-based variables: F #varNum based on radius range. + Variables + } + + /// + /// Defines a radius range and its associated feedrate for arc moves. + /// + public class ArcFeedrateRange + { + /// Maximum radius for this range (inclusive). + public double MaxRadius { get; set; } + + /// Feedrate as a fraction of process feedrate (e.g. 0.25 = 25%). + public double FeedratePercent { get; set; } + + /// Variable number for Variables mode (e.g. 123). + public int VariableNumber { get; set; } + } } diff --git a/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs b/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs index 4f764ff..c822989 100644 --- a/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs +++ b/OpenNest.Posts.Cincinnati/CincinnatiPostProcessor.cs @@ -112,8 +112,9 @@ namespace OpenNest.Posts.Cincinnati var sheetIndex = i + 1; var subNumber = Config.SheetSubprogramStart + i; var cutLibrary = resolver.ResolveCutLibrary(plate.Material?.Name ?? "", plate.Thickness, gas); + var isLastSheet = i == plates.Count - 1; sheetWriter.Write(writer, plate, nest.Name ?? "NEST", sheetIndex, subNumber, - cutLibrary, etchLibrary, partSubprograms); + cutLibrary, etchLibrary, partSubprograms, isLastSheet); } // Part sub-programs (if enabled) @@ -148,6 +149,18 @@ namespace OpenNest.Posts.Cincinnati vars.GetOrCreate("LeadInFeedrate", 126, $"[#148*{Config.LeadInFeedratePercent}]"); vars.GetOrCreate("LeadInArcLine2Feedrate", 127, $"[#148*{Config.LeadInArcLine2FeedratePercent}]"); vars.GetOrCreate("CircleFeedrate", 128, Config.CircleFeedrateMultiplier.ToString("0.#")); + vars.GetOrCreate("LeadOutFeedrate", 129, $"[#148*{Config.LeadOutFeedratePercent}]"); + + if (Config.ArcFeedrate == ArcFeedrateMode.Variables) + { + foreach (var range in Config.ArcFeedrateRanges) + { + var name = $"ArcFeedR{range.MaxRadius.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)}"; + vars.GetOrCreate(name, range.VariableNumber, + $"[#148*{range.FeedratePercent.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture)}]"); + } + } + return vars; } } diff --git a/OpenNest.Posts.Cincinnati/CincinnatiPreambleWriter.cs b/OpenNest.Posts.Cincinnati/CincinnatiPreambleWriter.cs index f119b42..85c830c 100644 --- a/OpenNest.Posts.Cincinnati/CincinnatiPreambleWriter.cs +++ b/OpenNest.Posts.Cincinnati/CincinnatiPreambleWriter.cs @@ -37,15 +37,21 @@ public sealed class CincinnatiPreambleWriter w.WriteLine(CoordinateFormatter.Comment("MAIN PROGRAM")); - w.WriteLine(_config.PostedUnits == Units.Millimeters ? "G21" : "G20"); + w.WriteLine(_config.PostedUnits == Units.Millimeters ? "G21 G90" : "G20 G90"); + + if (_config.UseSmartRapids) + w.WriteLine("G121 (SMART RAPIDS)"); w.WriteLine("M42"); if (_config.ProcessParameterMode == G89Mode.LibraryFile && !string.IsNullOrEmpty(initialLibrary)) - w.WriteLine($"G89 P {initialLibrary}"); + w.WriteLine($"G89 P{initialLibrary}"); w.WriteLine($"M98 P{_config.VariableDeclarationSubprogram} (Variable Declaration)"); + if (_config.PalletExchange == PalletMode.StartAndEnd) + w.WriteLine("M50"); + w.WriteLine("GOTO1 (GOTO SHEET NUMBER)"); for (var i = 1; i <= sheetCount; i++) diff --git a/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs b/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs index ad1fb13..084f876 100644 --- a/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs +++ b/OpenNest.Posts.Cincinnati/CincinnatiSheetWriter.cs @@ -37,7 +37,8 @@ public sealed class CincinnatiSheetWriter /// public void Write(TextWriter w, Plate plate, string nestName, int sheetIndex, int subNumber, string cutLibrary, string etchLibrary, - Dictionary<(int, long), int> partSubprograms = null) + Dictionary<(int, long), int> partSubprograms = null, + bool isLastSheet = false) { if (plate.Parts.Count == 0) return; @@ -64,12 +65,12 @@ public sealed class CincinnatiSheetWriter w.WriteLine("N10000"); w.WriteLine("G92 X#5021 Y#5022"); if (!string.IsNullOrEmpty(cutLibrary)) - w.WriteLine($"G89 P {cutLibrary}"); + w.WriteLine($"G89 P{cutLibrary}"); w.WriteLine($"M98 P{varDeclSub} (Variable Declaration)"); w.WriteLine("G90"); w.WriteLine("M47"); if (!string.IsNullOrEmpty(cutLibrary)) - w.WriteLine($"G89 P {cutLibrary}"); + w.WriteLine($"G89 P{cutLibrary}"); w.WriteLine("GOTO1( Goto Feature )"); // 3. Order parts: non-cutoff sorted by Bottom then Left, cutoffs last @@ -94,7 +95,9 @@ public sealed class CincinnatiSheetWriter // 5. Footer w.WriteLine("M42"); w.WriteLine("G0 X0 Y0"); - if (_config.PalletExchange != PalletMode.None) + var emitM50 = _config.PalletExchange == PalletMode.EndOfSheet + || (_config.PalletExchange == PalletMode.StartAndEnd && isLastSheet); + if (emitM50) w.WriteLine($"N{sheetIndex + 1} M50"); w.WriteLine($"M99 (END OF {nestName}.{sheetIndex:D3})"); } diff --git a/OpenNest.Posts.Cincinnati/FeatureUtils.cs b/OpenNest.Posts.Cincinnati/FeatureUtils.cs index 8fb3759..20fc501 100644 --- a/OpenNest.Posts.Cincinnati/FeatureUtils.cs +++ b/OpenNest.Posts.Cincinnati/FeatureUtils.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using OpenNest.CNC; using OpenNest.Geometry; +using OpenNest.Math; namespace OpenNest.Posts.Cincinnati; @@ -105,11 +106,48 @@ public static class FeatureUtils } else if (code is ArcMove arc) { - distance += currentPos.DistanceTo(arc.EndPoint); + distance += ComputeArcLength(currentPos, arc); currentPos = arc.EndPoint; } } return distance; } + + /// + /// Computes the arc length from the current position through an arc move. + /// Uses radius * sweep angle instead of chord length. + /// + public static double ComputeArcLength(Vector startPos, ArcMove arc) + { + var radius = startPos.DistanceTo(arc.CenterPoint); + if (radius < Tolerance.Epsilon) + return 0.0; + + // Full circle: start ≈ end + if (Tolerance.IsEqualTo(startPos.X, arc.EndPoint.X) + && Tolerance.IsEqualTo(startPos.Y, arc.EndPoint.Y)) + return 2.0 * System.Math.PI * radius; + + var startAngle = System.Math.Atan2( + startPos.Y - arc.CenterPoint.Y, + startPos.X - arc.CenterPoint.X); + var endAngle = System.Math.Atan2( + arc.EndPoint.Y - arc.CenterPoint.Y, + arc.EndPoint.X - arc.CenterPoint.X); + + double sweep; + if (arc.Rotation == RotationType.CW) + { + sweep = startAngle - endAngle; + if (sweep <= 0) sweep += 2.0 * System.Math.PI; + } + else + { + sweep = endAngle - startAngle; + if (sweep <= 0) sweep += 2.0 * System.Math.PI; + } + + return radius * sweep; + } } diff --git a/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs b/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs index bf88a41..860bc35 100644 --- a/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs +++ b/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs @@ -297,7 +297,7 @@ public class CincinnatiFeatureWriterTests ctx.SheetDiagonal = 30.0; var output = WriteFeature(config, ctx); - Assert.Contains("G89 P MILD10", output); + Assert.Contains("G89 PMILD10", output); } [Fact] @@ -470,6 +470,123 @@ public class CincinnatiFeatureWriterTests Assert.True(m131Idx < m47Idx, "M131 should come before M47"); } + [Fact] + public void LeadoutFeedrate_UsesVariable129() + { + var config = DefaultConfig(); + config.KerfCompensation = KerfMode.PreApplied; + var codes = new List + { + new RapidMove(1.0, 1.0), + new LinearMove(2.0, 1.0) { Layer = LayerType.Leadout } + }; + var ctx = SimpleContext(codes); + var output = WriteFeature(config, ctx); + + Assert.Contains("F#129", output); + } + + [Fact] + public void ArcLeadin_UsesVariable127() + { + var config = DefaultConfig(); + config.KerfCompensation = KerfMode.PreApplied; + var codes = new List + { + new RapidMove(10.0, 20.0), + new ArcMove(new Vector(12.0, 20.0), new Vector(11.0, 20.0), RotationType.CCW) { Layer = LayerType.Leadin } + }; + var ctx = SimpleContext(codes); + var output = WriteFeature(config, ctx); + + Assert.Contains("F#127", output); + } + + [Fact] + public void ArcFeedrate_VariablesMode_UsesRadiusRangeVariable() + { + var config = DefaultConfig(); + config.KerfCompensation = KerfMode.PreApplied; + config.ArcFeedrate = ArcFeedrateMode.Variables; + // Arc with radius 0.5 (center at 10.5, 20; start at 10, 20) → R=0.5 → #124 (R ≤ 0.750) + var codes = new List + { + new RapidMove(10.0, 20.0), + new ArcMove(new Vector(11.0, 20.0), new Vector(10.5, 20.0), RotationType.CW) { Layer = LayerType.Cut } + }; + var ctx = SimpleContext(codes); + var output = WriteFeature(config, ctx); + + Assert.Contains("F#124", output); + } + + [Fact] + public void ArcFeedrate_PercentagesMode_UsesInlineExpression() + { + var config = DefaultConfig(); + config.KerfCompensation = KerfMode.PreApplied; + config.ArcFeedrate = ArcFeedrateMode.Percentages; + // Arc with radius 0.1 (≤ 0.125) → 25% + var codes = new List + { + new RapidMove(10.0, 20.0), + new ArcMove(new Vector(10.2, 20.0), new Vector(10.1, 20.0), RotationType.CW) { Layer = LayerType.Cut } + }; + var ctx = SimpleContext(codes); + var output = WriteFeature(config, ctx); + + Assert.Contains("F[#148*0.25]", output); + } + + [Fact] + public void ArcFeedrate_LargeRadius_UsesProcessFeedrate() + { + var config = DefaultConfig(); + config.KerfCompensation = KerfMode.PreApplied; + config.ArcFeedrate = ArcFeedrateMode.Variables; + // Arc with radius 10 (> 4.500) → falls through to #148 + var codes = new List + { + new RapidMove(0.0, 0.0), + new ArcMove(new Vector(20.0, 0.0), new Vector(10.0, 0.0), RotationType.CCW) { Layer = LayerType.Cut } + }; + var ctx = SimpleContext(codes); + var output = WriteFeature(config, ctx); + + Assert.Contains("F#148", output); + } + + [Fact] + public void ArcFeedrate_NoneMode_UsesProcessFeedrateForNonCircle() + { + var config = DefaultConfig(); + config.KerfCompensation = KerfMode.PreApplied; + config.ArcFeedrate = ArcFeedrateMode.None; + // Small radius arc but mode is None → process feedrate + var codes = new List + { + new RapidMove(10.0, 20.0), + new ArcMove(new Vector(10.2, 20.0), new Vector(10.1, 20.0), RotationType.CW) { Layer = LayerType.Cut } + }; + var ctx = SimpleContext(codes); + var output = WriteFeature(config, ctx); + + Assert.Contains("F#148", output); + } + + [Fact] + public void G89_PAdjacentToFilename() + { + var config = DefaultConfig(); + var ctx = SimpleContext(); + ctx.LibraryFile = "MS135N2.lib"; + var output = WriteFeature(config, ctx); + + // P must be directly adjacent to filename, no space + Assert.Contains("G89 PMS135N2.lib", output); + Assert.DoesNotContain("G89 P MS135N2.lib", output); + } + private static int CountOccurrences(string text, string pattern) { var count = 0; diff --git a/OpenNest.Tests/Cincinnati/CincinnatiPostProcessorTests.cs b/OpenNest.Tests/Cincinnati/CincinnatiPostProcessorTests.cs index d99c237..1e004c8 100644 --- a/OpenNest.Tests/Cincinnati/CincinnatiPostProcessorTests.cs +++ b/OpenNest.Tests/Cincinnati/CincinnatiPostProcessorTests.cs @@ -43,6 +43,74 @@ public class CincinnatiPostProcessorTests Assert.Contains("M99", output); } + [Fact] + public void Post_EmitsLeadOutVariable() + { + var nest = CreateTestNest(); + var config = new CincinnatiPostConfig { PostedAccuracy = 4 }; + var post = new CincinnatiPostProcessor(config); + + using var ms = new MemoryStream(); + post.Post(nest, ms); + + var output = Encoding.UTF8.GetString(ms.ToArray()); + Assert.Contains("#129=", output); + } + + [Fact] + public void Post_WithArcFeedrateVariables_EmitsRangeVariables() + { + var nest = CreateTestNest(); + var config = new CincinnatiPostConfig + { + PostedAccuracy = 4, + ArcFeedrate = ArcFeedrateMode.Variables + }; + var post = new CincinnatiPostProcessor(config); + + using var ms = new MemoryStream(); + post.Post(nest, ms); + + var output = Encoding.UTF8.GetString(ms.ToArray()); + Assert.Contains("#123=", output); + Assert.Contains("#124=", output); + Assert.Contains("#125=", output); + } + + [Fact] + public void Post_WithArcFeedrateNone_OmitsRangeVariables() + { + var nest = CreateTestNest(); + var config = new CincinnatiPostConfig + { + PostedAccuracy = 4, + ArcFeedrate = ArcFeedrateMode.None + }; + var post = new CincinnatiPostProcessor(config); + + using var ms = new MemoryStream(); + post.Post(nest, ms); + + var output = Encoding.UTF8.GetString(ms.ToArray()); + Assert.DoesNotContain("#123=", output); + Assert.DoesNotContain("#124=", output); + Assert.DoesNotContain("#125=", output); + } + + [Fact] + public void Post_EmitsG90InPreamble() + { + var nest = CreateTestNest(); + var config = new CincinnatiPostConfig { PostedAccuracy = 4 }; + var post = new CincinnatiPostProcessor(config); + + using var ms = new MemoryStream(); + post.Post(nest, ms); + + var output = Encoding.UTF8.GetString(ms.ToArray()); + Assert.Contains("G20 G90", output); + } + [Fact] public void Post_ImplementsIPostProcessor() { diff --git a/OpenNest.Tests/Cincinnati/CincinnatiPreambleWriterTests.cs b/OpenNest.Tests/Cincinnati/CincinnatiPreambleWriterTests.cs index b663880..52eaa7a 100644 --- a/OpenNest.Tests/Cincinnati/CincinnatiPreambleWriterTests.cs +++ b/OpenNest.Tests/Cincinnati/CincinnatiPreambleWriterTests.cs @@ -24,9 +24,9 @@ public class CincinnatiPreambleWriterTests var output = sb.ToString(); Assert.Contains("( NEST TestNest )", output); Assert.Contains("( CONFIGURATION - CL940 )", output); - Assert.Contains("G20", output); + Assert.Contains("G20 G90", output); Assert.Contains("M42", output); - Assert.Contains("G89 P MS135N2PANEL.lib", output); + Assert.Contains("G89 PMS135N2PANEL.lib", output); Assert.Contains("M98 P100 (Variable Declaration)", output); Assert.Contains("GOTO1 (GOTO SHEET NUMBER)", output); Assert.Contains("N1 M98 P101 (SHEET 1)", output); @@ -44,7 +44,72 @@ public class CincinnatiPreambleWriterTests writer.WriteMainProgram(sw, "Test", "", 1, ""); - Assert.Contains("G21", sb.ToString()); + Assert.Contains("G21 G90", sb.ToString()); + } + + [Fact] + public void WriteMainProgram_EmitsG90WithUnits() + { + var config = new CincinnatiPostConfig { PostedUnits = Units.Inches }; + var sb = new StringBuilder(); + using var sw = new StringWriter(sb); + var writer = new CincinnatiPreambleWriter(config); + + writer.WriteMainProgram(sw, "Test", "", 1, ""); + + Assert.Contains("G20 G90", sb.ToString()); + } + + [Fact] + public void WriteMainProgram_EmitsG121_WhenSmartRapidsEnabled() + { + var config = new CincinnatiPostConfig { UseSmartRapids = true }; + var sb = new StringBuilder(); + using var sw = new StringWriter(sb); + var writer = new CincinnatiPreambleWriter(config); + + writer.WriteMainProgram(sw, "Test", "", 1, ""); + + Assert.Contains("G121 (SMART RAPIDS)", sb.ToString()); + } + + [Fact] + public void WriteMainProgram_OmitsG121_WhenSmartRapidsDisabled() + { + var config = new CincinnatiPostConfig { UseSmartRapids = false }; + var sb = new StringBuilder(); + using var sw = new StringWriter(sb); + var writer = new CincinnatiPreambleWriter(config); + + writer.WriteMainProgram(sw, "Test", "", 1, ""); + + Assert.DoesNotContain("G121", sb.ToString()); + } + + [Fact] + public void WriteMainProgram_EmitsM50_WhenStartAndEnd() + { + var config = new CincinnatiPostConfig { PalletExchange = PalletMode.StartAndEnd }; + var sb = new StringBuilder(); + using var sw = new StringWriter(sb); + var writer = new CincinnatiPreambleWriter(config); + + writer.WriteMainProgram(sw, "Test", "", 1, ""); + + Assert.Contains("M50", sb.ToString()); + } + + [Fact] + public void WriteMainProgram_OmitsM50_WhenEndOfSheet() + { + var config = new CincinnatiPostConfig { PalletExchange = PalletMode.EndOfSheet }; + var sb = new StringBuilder(); + using var sw = new StringWriter(sb); + var writer = new CincinnatiPreambleWriter(config); + + writer.WriteMainProgram(sw, "Test", "", 1, ""); + + Assert.DoesNotContain("M50", sb.ToString()); } [Fact] diff --git a/OpenNest.Tests/Cincinnati/CincinnatiSheetWriterTests.cs b/OpenNest.Tests/Cincinnati/CincinnatiSheetWriterTests.cs index 7d94cc4..3fdc8b0 100644 --- a/OpenNest.Tests/Cincinnati/CincinnatiSheetWriterTests.cs +++ b/OpenNest.Tests/Cincinnati/CincinnatiSheetWriterTests.cs @@ -32,7 +32,7 @@ public class CincinnatiSheetWriterTests Assert.Contains("#110=", output); Assert.Contains("#111=", output); Assert.Contains("G92 X#5021 Y#5022", output); - Assert.Contains("G89 P MS135N2PANEL.lib", output); + Assert.Contains("G89 PMS135N2PANEL.lib", output); Assert.Contains("M99", output); } @@ -137,9 +137,110 @@ public class CincinnatiSheetWriterTests Assert.True(g85Idx < g84Idx, "G85 (etch) should come before G84 (cut)"); // Etch uses etch library - Assert.Contains("G89 P EtchN2.lib", output); + Assert.Contains("G89 PEtchN2.lib", output); // Cut uses cut library - Assert.Contains("G89 P MS250O2.lib", output); + Assert.Contains("G89 PMS250O2.lib", output); + } + + [Fact] + public void WriteSheet_StartAndEnd_NoM50OnNonLastSheet() + { + var config = new CincinnatiPostConfig + { + PalletExchange = PalletMode.StartAndEnd, + PostedAccuracy = 4 + }; + var plate = new Plate(48.0, 96.0); + plate.Parts.Add(new Part(new Drawing("TestPart", CreateSimpleProgram()))); + + var sb = new StringBuilder(); + using var sw = new StringWriter(sb); + var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager()); + + sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "", isLastSheet: false); + + var output = sb.ToString(); + Assert.DoesNotContain("M50", output); + } + + [Fact] + public void WriteSheet_StartAndEnd_M50OnLastSheet() + { + var config = new CincinnatiPostConfig + { + PalletExchange = PalletMode.StartAndEnd, + PostedAccuracy = 4 + }; + var plate = new Plate(48.0, 96.0); + plate.Parts.Add(new Part(new Drawing("TestPart", CreateSimpleProgram()))); + + var sb = new StringBuilder(); + using var sw = new StringWriter(sb); + var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager()); + + sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "", isLastSheet: true); + + var output = sb.ToString(); + Assert.Contains("M50", output); + } + + [Fact] + public void WriteSheet_EndOfSheet_AlwaysEmitsM50() + { + var config = new CincinnatiPostConfig + { + PalletExchange = PalletMode.EndOfSheet, + PostedAccuracy = 4 + }; + var plate = new Plate(48.0, 96.0); + plate.Parts.Add(new Part(new Drawing("TestPart", CreateSimpleProgram()))); + + var sb = new StringBuilder(); + using var sw = new StringWriter(sb); + var sheetWriter = new CincinnatiSheetWriter(config, new ProgramVariableManager()); + + sheetWriter.Write(sw, plate, "TestNest", 1, 101, "", "", isLastSheet: false); + + var output = sb.ToString(); + Assert.Contains("M50", output); + } + + [Fact] + public void ComputeArcLength_FullCircle_Returns2PiR() + { + var start = new Vector(10.0, 20.0); + var arc = new ArcMove(new Vector(10.0, 20.0), new Vector(15.0, 20.0), RotationType.CW); + var length = FeatureUtils.ComputeArcLength(start, arc); + + // Radius = 5, full circle = 2 * PI * 5 ≈ 31.416 + Assert.Equal(2.0 * System.Math.PI * 5.0, length, 4); + } + + [Fact] + public void ComputeArcLength_Semicircle_ReturnsPiR() + { + // Semicircle from (0,0) to (10,0) with center at (5,0), CCW → goes through (5,5) + var start = new Vector(0.0, 0.0); + var arc = new ArcMove(new Vector(10.0, 0.0), new Vector(5.0, 0.0), RotationType.CCW); + var length = FeatureUtils.ComputeArcLength(start, arc); + + // Radius = 5, semicircle = PI * 5 ≈ 15.708 + Assert.Equal(System.Math.PI * 5.0, length, 4); + } + + [Fact] + public void ComputeCutDistance_WithArcs_UsesArcLengthNotChord() + { + // Full circle: chord = 0 but arc length = 2πr + var codes = new List + { + new RapidMove(10.0, 20.0), + new ArcMove(new Vector(10.0, 20.0), new Vector(15.0, 20.0), RotationType.CW) { Layer = LayerType.Cut } + }; + var distance = FeatureUtils.ComputeCutDistance(codes); + + // Full circle with R=5 → 2πr ≈ 31.416 + Assert.True(distance > 30.0, $"Expected arc length > 30 but got {distance}"); } [Fact]