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