feat: add CincinnatiFeatureWriter for per-feature G-code emission
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
230
OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs
Normal file
230
OpenNest.Posts.Cincinnati/CincinnatiFeatureWriter.cs
Normal file
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data class carrying all context needed to emit one Cincinnati-format G-code feature block.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FeatureContext
|
||||||
|
{
|
||||||
|
public List<ICode> 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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a complete feature block for the given context.
|
||||||
|
/// </summary>
|
||||||
|
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<ICode> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
437
OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs
Normal file
437
OpenNest.Tests/Cincinnati/CincinnatiFeatureWriterTests.cs
Normal file
@@ -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<ICode>? codes = null) => new()
|
||||||
|
{
|
||||||
|
Codes = codes ?? new List<ICode>
|
||||||
|
{
|
||||||
|
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<ICode>
|
||||||
|
{
|
||||||
|
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<ICode>
|
||||||
|
{
|
||||||
|
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<ICode>
|
||||||
|
{
|
||||||
|
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<ICode>
|
||||||
|
{
|
||||||
|
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<ICode>
|
||||||
|
{
|
||||||
|
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<ICode>
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user