From e9caa9b8eb664dc159d30b8fe7dc418e6066db09 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 22 Mar 2026 23:33:06 -0400 Subject: [PATCH] feat: add CincinnatiFeatureWriter for per-feature G-code emission Co-Authored-By: Claude Opus 4.6 (1M context) --- .../CincinnatiFeatureWriter.cs | 230 +++++++++ .../CincinnatiFeatureWriterTests.cs | 437 ++++++++++++++++++ 2 files changed, 667 insertions(+) create mode 100644 OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs create mode 100644 OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs diff --git a/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs b/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs new file mode 100644 index 0000000..6435afe --- /dev/null +++ b/OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs @@ -0,0 +1,230 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; +using OpenNest.CNC; +using OpenNest.Geometry; +using OpenNest.Math; + +namespace OpenNest.Posts.Cincinnati; + +/// +/// Data class carrying all context needed to emit one Cincinnati-format G-code feature block. +/// +public sealed class FeatureContext +{ + public List Codes { get; set; } = new(); + public int FeatureNumber { get; set; } + public string PartName { get; set; } = ""; + public bool IsFirstFeatureOfPart { get; set; } + public bool IsLastFeatureOnSheet { get; set; } + public bool IsSafetyHeadraise { get; set; } + public bool IsExteriorFeature { get; set; } + public string LibraryFile { get; set; } = ""; + public double CutDistance { get; set; } + public double SheetDiagonal { get; set; } +} + +/// +/// Emits one Cincinnati-format G-code feature block (one contour) to a TextWriter. +/// Handles rapid positioning, pierce, kerf compensation, anti-dive, feedrate modal +/// suppression, arc I/J conversion (absolute to incremental), and M47 head raise. +/// +public sealed class CincinnatiFeatureWriter +{ + private readonly CincinnatiPostConfig _config; + private readonly CoordinateFormatter _fmt; + private readonly SpeedClassifier _speedClassifier; + + public CincinnatiFeatureWriter(CincinnatiPostConfig config) + { + _config = config; + _fmt = new CoordinateFormatter(config.PostedAccuracy); + _speedClassifier = new SpeedClassifier(); + } + + /// + /// Writes a complete feature block for the given context. + /// + public void Write(TextWriter writer, FeatureContext ctx) + { + var currentPos = Vector.Zero; + var lastFeedVar = ""; + var kerfEmitted = false; + + // Find the pierce point from the first rapid move + var piercePoint = FindPiercePoint(ctx.Codes); + + // 1. Rapid to pierce point (with line number if configured) + WriteRapidToPierce(writer, ctx.FeatureNumber, piercePoint); + + // 2. Part name comment on first feature of each part + if (ctx.IsFirstFeatureOfPart && !string.IsNullOrEmpty(ctx.PartName)) + writer.WriteLine(CoordinateFormatter.Comment($"PART: {ctx.PartName}")); + + // 3. G89 process params (if RepeatG89BeforeEachFeature) + if (_config.RepeatG89BeforeEachFeature && _config.ProcessParameterMode == G89Mode.LibraryFile) + { + var lib = !string.IsNullOrEmpty(ctx.LibraryFile) ? ctx.LibraryFile : _config.DefaultLibraryFile; + var speedClass = _speedClassifier.Classify(ctx.CutDistance, ctx.SheetDiagonal); + var cutDist = _speedClassifier.FormatCutDist(ctx.CutDistance, ctx.SheetDiagonal); + writer.WriteLine($"G89 P {lib} ({speedClass} {cutDist})"); + } + + // 4. Pierce and start cut + writer.WriteLine("G84"); + + // 5. Anti-dive off + if (_config.UseAntiDive) + writer.WriteLine("M130 (ANTI DIVE OFF)"); + + // Update current position to pierce point + currentPos = piercePoint; + + // 6. Lead-in + contour moves with kerf comp and feedrate variables + foreach (var code in ctx.Codes) + { + if (code is RapidMove) + continue; // skip rapids in contour (already handled above) + + if (code is LinearMove linear) + { + var sb = new StringBuilder(); + + // Kerf compensation on first cutting move + if (!kerfEmitted && _config.KerfCompensation == KerfMode.ControllerSide) + { + sb.Append(_config.DefaultKerfSide == KerfSide.Left ? "G41" : "G42"); + kerfEmitted = true; + } + + sb.Append($"G1X{_fmt.FormatCoord(linear.EndPoint.X)}Y{_fmt.FormatCoord(linear.EndPoint.Y)}"); + + // Feedrate + var feedVar = GetFeedVariable(linear.Layer); + if (feedVar != lastFeedVar) + { + sb.Append($"F{feedVar}"); + lastFeedVar = feedVar; + } + + writer.WriteLine(sb.ToString()); + currentPos = linear.EndPoint; + } + else if (code is ArcMove arc) + { + var sb = new StringBuilder(); + + // Kerf compensation on first cutting move + if (!kerfEmitted && _config.KerfCompensation == KerfMode.ControllerSide) + { + sb.Append(_config.DefaultKerfSide == KerfSide.Left ? "G41" : "G42"); + kerfEmitted = true; + } + + // G2 = CW, G3 = CCW + var gCode = arc.Rotation == RotationType.CW ? "G2" : "G3"; + sb.Append($"{gCode}X{_fmt.FormatCoord(arc.EndPoint.X)}Y{_fmt.FormatCoord(arc.EndPoint.Y)}"); + + // Convert absolute center to incremental I/J + var i = arc.CenterPoint.X - currentPos.X; + var j = arc.CenterPoint.Y - currentPos.Y; + sb.Append($"I{_fmt.FormatCoord(i)}J{_fmt.FormatCoord(j)}"); + + // Feedrate — full circles use multiplied feedrate + var isFullCircle = IsFullCircle(currentPos, arc.EndPoint); + var feedVar = isFullCircle ? "[#148*#128]" : GetFeedVariable(arc.Layer); + if (feedVar != lastFeedVar) + { + sb.Append($"F{feedVar}"); + lastFeedVar = feedVar; + } + + writer.WriteLine(sb.ToString()); + currentPos = arc.EndPoint; + } + } + + // 7. Cancel kerf compensation + if (kerfEmitted) + writer.WriteLine("G40"); + + // 8. Beam off + writer.WriteLine(_config.UseSpeedGas ? "M135" : "M35"); + + // 9. Anti-dive on + if (_config.UseAntiDive) + writer.WriteLine("M131 (ANTI DIVE ON)"); + + // 10. Head raise (unless last feature on sheet) + if (!ctx.IsLastFeatureOnSheet) + WriteM47(writer, ctx); + } + + private Vector FindPiercePoint(List codes) + { + foreach (var code in codes) + { + if (code is RapidMove rapid) + return rapid.EndPoint; + } + + // If no rapid move, use the endpoint of the first motion + foreach (var code in codes) + { + if (code is Motion motion) + return motion.EndPoint; + } + + return Vector.Zero; + } + + private void WriteRapidToPierce(TextWriter writer, int featureNumber, Vector piercePoint) + { + var sb = new StringBuilder(); + + if (_config.UseLineNumbers) + sb.Append($"N{featureNumber}"); + + sb.Append($"G0X{_fmt.FormatCoord(piercePoint.X)}Y{_fmt.FormatCoord(piercePoint.Y)}"); + + writer.WriteLine(sb.ToString()); + } + + private void WriteM47(TextWriter writer, FeatureContext ctx) + { + if (ctx.IsSafetyHeadraise && _config.SafetyHeadraiseDistance.HasValue) + { + writer.WriteLine($"M47 P{_config.SafetyHeadraiseDistance.Value}(Safety Headraise)"); + return; + } + + var mode = ctx.IsExteriorFeature ? _config.ExteriorM47 : _config.InteriorM47; + + switch (mode) + { + case M47Mode.Always: + writer.WriteLine("M47"); + break; + case M47Mode.BlockDelete: + writer.WriteLine("/M47"); + break; + case M47Mode.None: + break; + } + } + + private static string GetFeedVariable(LayerType layer) + { + return layer switch + { + LayerType.Leadin => "#126", + LayerType.Cut => "#148", + _ => "#148" + }; + } + + 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.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs b/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs new file mode 100644 index 0000000..450544c --- /dev/null +++ b/OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs @@ -0,0 +1,437 @@ +using OpenNest.CNC; +using OpenNest.Geometry; +using OpenNest.Posts.Cincinnati; + +namespace OpenNest.Tests.Cincinnati; + +public class CincinnatiFeatureWriterTests +{ + private static CincinnatiPostConfig DefaultConfig() => new() + { + UseLineNumbers = true, + FeatureLineNumberStart = 1, + UseAntiDive = true, + KerfCompensation = KerfMode.ControllerSide, + DefaultKerfSide = KerfSide.Left, + RepeatG89BeforeEachFeature = true, + ProcessParameterMode = G89Mode.LibraryFile, + DefaultLibraryFile = "MILD10", + InteriorM47 = M47Mode.Always, + ExteriorM47 = M47Mode.Always, + UseSpeedGas = false, + PostedAccuracy = 4, + SafetyHeadraiseDistance = 2000 + }; + + private static FeatureContext SimpleContext(List? codes = null) => new() + { + Codes = codes ?? new List + { + new RapidMove(13.401, 57.4895), + new LinearMove(14.0, 57.5) { Layer = LayerType.Leadin }, + new LinearMove(20.0, 57.5) { Layer = LayerType.Cut } + }, + FeatureNumber = 1, + PartName = "BRACKET", + IsFirstFeatureOfPart = true, + IsLastFeatureOnSheet = false, + IsSafetyHeadraise = false, + IsExteriorFeature = false, + LibraryFile = "MILD10", + CutDistance = 18.0, + SheetDiagonal = 30.0 + }; + + private static string WriteFeature(CincinnatiPostConfig config, FeatureContext ctx) + { + var writer = new CincinnatiFeatureWriter(config); + using var sw = new StringWriter(); + writer.Write(sw, ctx); + return sw.ToString(); + } + + [Fact] + public void RapidToPiercePoint_WithLineNumber() + { + var config = DefaultConfig(); + var ctx = SimpleContext(); + var output = WriteFeature(config, ctx); + var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + + Assert.StartsWith("N1G0X13.401Y57.4895", lines[0]); + } + + [Fact] + public void RapidToPiercePoint_WithoutLineNumber() + { + var config = DefaultConfig(); + config.UseLineNumbers = false; + var ctx = SimpleContext(); + var output = WriteFeature(config, ctx); + var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + + Assert.StartsWith("G0X13.401Y57.4895", lines[0]); + } + + [Fact] + public void G84_PierceEmitted() + { + var config = DefaultConfig(); + var ctx = SimpleContext(); + var output = WriteFeature(config, ctx); + + Assert.Contains("G84", output); + } + + [Fact] + public void AntiDive_M130M131_EmittedWhenEnabled() + { + var config = DefaultConfig(); + config.UseAntiDive = true; + var ctx = SimpleContext(); + var output = WriteFeature(config, ctx); + + Assert.Contains("M130 (ANTI DIVE OFF)", output); + Assert.Contains("M131 (ANTI DIVE ON)", output); + } + + [Fact] + public void AntiDive_NotEmittedWhenDisabled() + { + var config = DefaultConfig(); + config.UseAntiDive = false; + var ctx = SimpleContext(); + var output = WriteFeature(config, ctx); + + Assert.DoesNotContain("M130", output); + Assert.DoesNotContain("M131", output); + } + + [Fact] + public void KerfCompensation_G41G40_EmittedWhenControllerSide() + { + var config = DefaultConfig(); + config.KerfCompensation = KerfMode.ControllerSide; + config.DefaultKerfSide = KerfSide.Left; + var ctx = SimpleContext(); + var output = WriteFeature(config, ctx); + + Assert.Contains("G41", output); + Assert.Contains("G40", output); + } + + [Fact] + public void KerfCompensation_G42_EmittedForRightSide() + { + var config = DefaultConfig(); + config.KerfCompensation = KerfMode.ControllerSide; + config.DefaultKerfSide = KerfSide.Right; + var ctx = SimpleContext(); + var output = WriteFeature(config, ctx); + + Assert.Contains("G42", output); + } + + [Fact] + public void KerfCompensation_NotEmittedWhenPreApplied() + { + var config = DefaultConfig(); + config.KerfCompensation = KerfMode.PreApplied; + var ctx = SimpleContext(); + var output = WriteFeature(config, ctx); + + Assert.DoesNotContain("G41", output); + Assert.DoesNotContain("G42", output); + Assert.DoesNotContain("G40", output); + } + + [Fact] + public void M35_BeamOffEmitted() + { + var config = DefaultConfig(); + config.UseSpeedGas = false; + var ctx = SimpleContext(); + var output = WriteFeature(config, ctx); + + Assert.Contains("M35", output); + } + + [Fact] + public void M135_BeamOffEmittedWhenSpeedGas() + { + var config = DefaultConfig(); + config.UseSpeedGas = true; + var ctx = SimpleContext(); + var output = WriteFeature(config, ctx); + + Assert.Contains("M135", output); + } + + [Fact] + public void M47_EmittedWhenNotLastFeature() + { + var config = DefaultConfig(); + var ctx = SimpleContext(); + ctx.IsLastFeatureOnSheet = false; + var output = WriteFeature(config, ctx); + + Assert.Contains("M47", output); + } + + [Fact] + public void M47_OmittedWhenLastFeatureOnSheet() + { + var config = DefaultConfig(); + var ctx = SimpleContext(); + ctx.IsLastFeatureOnSheet = true; + var output = WriteFeature(config, ctx); + + // M47 should not appear, but M35 should still be there + var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Assert.DoesNotContain(lines, l => l.Trim() == "M47" || l.Trim() == "/M47"); + } + + [Fact] + public void M47_BlockDeleteMode_EmitsSlashM47() + { + var config = DefaultConfig(); + config.InteriorM47 = M47Mode.BlockDelete; + var ctx = SimpleContext(); + ctx.IsExteriorFeature = false; + ctx.IsLastFeatureOnSheet = false; + var output = WriteFeature(config, ctx); + + Assert.Contains("/M47", output); + } + + [Fact] + public void M47_NoneMode_NoM47Emitted() + { + var config = DefaultConfig(); + config.InteriorM47 = M47Mode.None; + var ctx = SimpleContext(); + ctx.IsExteriorFeature = false; + ctx.IsLastFeatureOnSheet = false; + var output = WriteFeature(config, ctx); + + var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + Assert.DoesNotContain(lines, l => l.Trim() == "M47" || l.Trim() == "/M47"); + } + + [Fact] + public void ArcIJ_ConvertedFromAbsoluteToIncremental() + { + var config = DefaultConfig(); + // Arc starts at rapid endpoint (10, 20), center at (15, 20) absolute + // So incremental I = 15 - 10 = 5, J = 20 - 20 = 0 + var codes = new List + { + new RapidMove(10.0, 20.0), + new ArcMove( + endPoint: new Vector(10.0, 20.0), + centerPoint: new Vector(15.0, 20.0), + rotation: RotationType.CW + ) { Layer = LayerType.Cut } + }; + + var ctx = SimpleContext(codes); + var output = WriteFeature(config, ctx); + + // Should contain incremental I=5, J=0 + Assert.Contains("I5", output); + Assert.Contains("J0", output); + } + + [Fact] + public void ArcMove_G2ForCW_G3ForCCW() + { + var config = DefaultConfig(); + var cwCodes = new List + { + new RapidMove(10.0, 20.0), + new ArcMove(new Vector(20.0, 20.0), new Vector(15.0, 20.0), RotationType.CW) { Layer = LayerType.Cut } + }; + var ccwCodes = new List + { + new RapidMove(10.0, 20.0), + new ArcMove(new Vector(20.0, 20.0), new Vector(15.0, 20.0), RotationType.CCW) { Layer = LayerType.Cut } + }; + + var cwOutput = WriteFeature(config, SimpleContext(cwCodes)); + var ccwOutput = WriteFeature(config, SimpleContext(ccwCodes)); + + Assert.Contains("G2X", cwOutput); + Assert.Contains("G3X", ccwOutput); + } + + [Fact] + public void PartNameComment_EmittedOnFirstFeature() + { + var config = DefaultConfig(); + var ctx = SimpleContext(); + ctx.IsFirstFeatureOfPart = true; + ctx.PartName = "FLANGE"; + var output = WriteFeature(config, ctx); + + Assert.Contains("( PART: FLANGE )", output); + } + + [Fact] + public void PartNameComment_NotEmittedOnSubsequentFeatures() + { + var config = DefaultConfig(); + var ctx = SimpleContext(); + ctx.IsFirstFeatureOfPart = false; + ctx.PartName = "FLANGE"; + var output = WriteFeature(config, ctx); + + Assert.DoesNotContain("PART:", output); + } + + [Fact] + public void G89_EmittedWhenRepeatEnabled() + { + var config = DefaultConfig(); + config.RepeatG89BeforeEachFeature = true; + config.ProcessParameterMode = G89Mode.LibraryFile; + config.DefaultLibraryFile = "MILD10"; + var ctx = SimpleContext(); + ctx.LibraryFile = "MILD10"; + ctx.CutDistance = 18.0; + ctx.SheetDiagonal = 30.0; + var output = WriteFeature(config, ctx); + + Assert.Contains("G89 P MILD10", output); + } + + [Fact] + public void G89_NotEmittedWhenRepeatDisabled() + { + var config = DefaultConfig(); + config.RepeatG89BeforeEachFeature = false; + var ctx = SimpleContext(); + var output = WriteFeature(config, ctx); + + Assert.DoesNotContain("G89", output); + } + + [Fact] + public void FeedrateModalSuppression_OnlyEmitsOnChange() + { + var config = DefaultConfig(); + config.KerfCompensation = KerfMode.PreApplied; // simplify output + var codes = new List + { + new RapidMove(1.0, 1.0), + new LinearMove(2.0, 1.0) { Layer = LayerType.Cut }, + new LinearMove(3.0, 1.0) { Layer = LayerType.Cut }, + new LinearMove(4.0, 1.0) { Layer = LayerType.Cut } + }; + var ctx = SimpleContext(codes); + var output = WriteFeature(config, ctx); + + // F#148 should appear only once (on the first cut move) + var count = CountOccurrences(output, "F#148"); + Assert.Equal(1, count); + } + + [Fact] + public void LeadinFeedrate_UsesVariable126() + { + var config = DefaultConfig(); + config.KerfCompensation = KerfMode.PreApplied; // simplify output + var codes = new List + { + new RapidMove(1.0, 1.0), + new LinearMove(2.0, 1.0) { Layer = LayerType.Leadin } + }; + var ctx = SimpleContext(codes); + var output = WriteFeature(config, ctx); + + Assert.Contains("F#126", output); + } + + [Fact] + public void FullCircleArc_UsesMultipliedFeedrate() + { + var config = DefaultConfig(); + config.KerfCompensation = KerfMode.PreApplied; + // Full circle: start == end + 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 ctx = SimpleContext(codes); + var output = WriteFeature(config, ctx); + + Assert.Contains("F[#148*#128]", output); + } + + [Fact] + public void SafetyHeadraise_EmitsM47WithDistance() + { + var config = DefaultConfig(); + config.SafetyHeadraiseDistance = 2000; + var ctx = SimpleContext(); + ctx.IsSafetyHeadraise = true; + ctx.IsLastFeatureOnSheet = false; + var output = WriteFeature(config, ctx); + + Assert.Contains("M47 P2000(Safety Headraise)", output); + } + + [Fact] + public void ExteriorM47Mode_UsesExteriorConfig() + { + var config = DefaultConfig(); + config.ExteriorM47 = M47Mode.BlockDelete; + config.InteriorM47 = M47Mode.Always; + var ctx = SimpleContext(); + ctx.IsExteriorFeature = true; + ctx.IsLastFeatureOnSheet = false; + var output = WriteFeature(config, ctx); + + Assert.Contains("/M47", output); + } + + [Fact] + public void OutputSequence_CorrectOrder() + { + var config = DefaultConfig(); + var ctx = SimpleContext(); + var output = WriteFeature(config, ctx); + var lines = output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + + // Find indices of key lines + var rapidIdx = Array.FindIndex(lines, l => l.Contains("G0X")); + var partIdx = Array.FindIndex(lines, l => l.Contains("PART:")); + var g89Idx = Array.FindIndex(lines, l => l.Contains("G89")); + var g84Idx = Array.FindIndex(lines, l => l.Contains("G84")); + var m130Idx = Array.FindIndex(lines, l => l.Contains("M130")); + var g40Idx = Array.FindIndex(lines, l => l.Contains("G40")); + var m35Idx = Array.FindIndex(lines, l => l.Contains("M35")); + var m131Idx = Array.FindIndex(lines, l => l.Contains("M131")); + var m47Idx = Array.FindIndex(lines, l => l.Trim() == "M47"); + + Assert.True(rapidIdx < partIdx, "Rapid should come before part comment"); + Assert.True(partIdx < g89Idx, "Part comment should come before G89"); + Assert.True(g89Idx < g84Idx, "G89 should come before G84"); + Assert.True(g84Idx < m130Idx, "G84 should come before M130"); + Assert.True(g40Idx < m35Idx, "G40 should come before M35"); + Assert.True(m35Idx < m131Idx, "M35 should come before M131"); + Assert.True(m131Idx < m47Idx, "M131 should come before M47"); + } + + private static int CountOccurrences(string text, string pattern) + { + var count = 0; + var idx = 0; + while ((idx = text.IndexOf(pattern, idx, StringComparison.Ordinal)) != -1) + { + count++; + idx += pattern.Length; + } + return count; + } +}