Files
OpenNest/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs
T
aj 6803d7519f fix(posts): post CI Fiber cut-offs in their sequence, not last
b0997f6 moved every cut-off after the parts. Plate.Parts order is the
cut sequence, and the user sets each cut-off's place in it (Set
Sequence), so the post must follow it rather than reorder. Cut-offs
still post as uncompensated open lines with no lead-in.
2026-09-28 18:41:06 -04:00

572 lines
22 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using OpenNest.CNC;
using OpenNest.Geometry;
using OpenNest.Posts.CincinnatiCIFiber;
namespace OpenNest.Tests.CincinnatiCIFiber;
/// <summary>
/// Output-contract tests for the Cincinnati CI Fiber post, checked against the
/// structure of the machine sample NC (12992-4SS_NEST.nc): header block,
/// restart jump, N-labelled contour blocks with /L macro lines, G41/G42
/// selection, trimmed coordinate format and the M50/M30/% tail.
/// </summary>
public class CIFiberPostProcessorTests
{
private static CIFiberPostConfig MakeConfig() =>
new() { ConfigurationName = "CI FIBER 8K" };
private static string Post(Nest nest, CIFiberPostConfig? config = null)
{
var post = new CIFiberPostProcessor(config ?? MakeConfig());
using var ms = new MemoryStream();
post.Post(nest, ms);
return Encoding.UTF8.GetString(ms.ToArray());
}
/// <summary>
/// 10x10 CW square perimeter (closed loop, no leading rapid) at the plate
/// location, with one CCW full-circle hole posted as an incremental
/// SubProgramCall at (5,5) — the exact shape OpenNest nests produce.
/// </summary>
private static Nest MakeSquareWithHoleNest(Vector? partLocation = null)
{
var location = partLocation ?? new Vector(1.0, 2.0);
var pgm = new Program(Mode.Absolute);
// Hole sub-program: CCW full circle r=0.5 centred on the call offset,
// linear lead-in from below, left absolute then flipped to
// incremental — exactly what ContourCuttingStrategy emits for a hole.
var hole = new Program(Mode.Absolute);
hole.Codes.Add(new RapidMove(new Vector(0, -0.6)));
hole.Codes.Add(new LinearMove(new Vector(0, -0.5)) { Layer = LayerType.Leadin });
hole.Codes.Add(
new ArcMove(new Vector(0, -0.5), new Vector(0, 0), RotationType.CCW)
{
Layer = LayerType.Cut,
}
);
hole.Mode = Mode.Incremental;
pgm.Codes.Add(new SubProgramCall { Id = 1, Program = hole, Offset = new Vector(5, 5) });
// Perimeter: linear lead-in into a CW loop starting at (0,0).
pgm.Codes.Add(new RapidMove(new Vector(0, -0.5)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)) { Layer = LayerType.Leadin });
pgm.Codes.Add(new LinearMove(new Vector(0, 10)));
pgm.Codes.Add(new LinearMove(new Vector(10, 10)));
pgm.Codes.Add(new LinearMove(new Vector(10, 0)));
pgm.Codes.Add(new LinearMove(new Vector(0, 0)));
var drawing = new Drawing("square-hole", pgm);
var part = new Part(drawing, location);
var plate = new Plate(60, 120);
plate.Parts.Add(part);
var nest = new Nest("Test Nest") { Thickness = 0.06, Material = new Material("Mild Steel") };
nest.Plates.Add(plate);
return nest;
}
private static List<string> Lines(string output) =>
output.Replace("\r\n", "\n").Split('\n').ToList();
[Fact]
public void Post_Structure_GoldenSquareWithHole()
{
var output = Post(MakeSquareWithHoleNest());
var lines = Lines(output);
// File ends with % and uses CRLF throughout, no BOM.
Assert.StartsWith("(", output);
Assert.EndsWith("%\r\n", output);
foreach (var raw in output.Split('\n').SkipLast(1))
Assert.EndsWith("\r", raw);
// Header block
Assert.Equal("( Test Nest )", lines[0]);
Assert.Equal("( CONFIGURATION - CI FIBER 8K )", lines[1]);
Assert.Matches(@"^\( \w+ \d+, \d{4} \d\d:\d\d (AM|PM) \)$", lines[2]);
Assert.Equal("( Material = Mild Steel .060 )", lines[3]);
Assert.Equal("V.E.MATERIAL = \"MSN\"", lines[4]);
Assert.Equal("V.E.THICKNESS = 0.060", lines[5]);
Assert.Equal("V.E.X_SIZE = 120.000", lines[6]);
Assert.Equal("V.E.Y_SIZE = 60.000", lines[7]);
Assert.Equal("V.E.UNIT = 1", lines[8]);
Assert.DoesNotContain("V.E.SHEET_WEIGHT", output);
// Restart preamble
Assert.Equal("G90", lines[9]);
Assert.Equal("L PROGRAMSTART.NC", lines[10]);
Assert.Equal("P3=V.E.R3", lines[11]);
Assert.Equal("$GOTO NP3:", lines[12]);
Assert.Equal("N0:", lines[13]);
Assert.Equal("( Sheet number - 1 )", lines[14]);
// Part block
Assert.Equal("( Part #1 )", lines[15]);
Assert.Equal("( PART:square-hole )", lines[16]);
Assert.Equal("V.E.R4=1", lines[17]);
// Contour 1 = the flattened hole (part program order).
Assert.Equal("N1:", lines[18]);
Assert.Equal("/L \"L0\"", lines[19]);
Assert.Equal("V.E.R3=1", lines[20]);
Assert.Equal("G0X6Y6.4", lines[21]); // pierce = part(1,2) + hole(5,5) + (0,-0.6)
Assert.Equal("/L \"L2\"", lines[22]);
Assert.Equal("G41", lines[23]);
Assert.Equal("G1X6Y6.5", lines[24]); // contour start = hole centre + (0,-0.5)
Assert.Equal("/L \"L6\"", lines[25]);
Assert.Equal("G3X6Y6.5I0J0.5", lines[26]); // I/J incremental from arc start
Assert.Equal("/L \"ZHSOFF\"", lines[27]);
// Contour 2 = perimeter.
Assert.Equal("N2:", lines[28]);
Assert.Equal("/L \"L0\"", lines[29]);
Assert.Equal("V.E.R3=2", lines[30]);
Assert.Equal("G0X1Y1.5", lines[31]);
Assert.Equal("/L \"L4\"", lines[32]);
Assert.Equal("G42", lines[33]);
Assert.Equal("G1X1Y2", lines[34]);
Assert.Equal("/L \"L6\"", lines[35]);
Assert.Equal("G1X1Y12", lines[36]);
Assert.Equal("G1X11Y12", lines[37]);
Assert.Equal("G1X11Y2", lines[38]);
Assert.Equal("G1X1Y2", lines[39]);
Assert.Equal("/L \"ZHSOFF\"", lines[40]);
// Part end and tail
Assert.Equal("( PART END )", lines[41]);
Assert.Equal("/L \"L0\"", lines[42]);
Assert.Equal("L PROGRAMEND.NC", lines[43]);
Assert.Equal("M50", lines[44]);
Assert.Equal("M30", lines[45]);
Assert.Equal("%", lines[46]);
}
[Fact]
public void Post_FlattensRotatedPartHole_ToSheetCoords()
{
// Same nest, but the part is rotated 90 degrees CCW about the origin:
// (x,y) -> (-y,x). Location (1,2) -> (-2,1); hole offset (5,5) -> (-5,5).
var nest = MakeSquareWithHoleNest();
var part = nest.Plates[0].Parts[0];
part.Rotate(OpenNest.Math.Angle.ToRadians(90));
var output = Post(nest);
var lines = Lines(output);
// Hole pierce local (0,-0.6) -> (0.6,0): (-2,1)+(-5,5)+(0.6,0) = (-6.4,6).
Assert.Equal("G0X-6.4Y6", lines[21]);
// Contour start local (0,-0.5) -> (0.5,0) = (-6.5,6).
Assert.Equal("G1X-6.5Y6", lines[24]);
// Full CCW circle, centre local (0,0.5) from the start -> (-0.5,0):
// centre at (-7,6), so I=-0.5 J=0.
Assert.Equal("G3X-6.5Y6I-0.5J0", lines[26]);
// Perimeter corner local (0,10) -> (-10,0): pierce (-2,1)+(0.5,0)...
// pierce local (0,-0.5)->(0.5,0) = (-1.5,1); corner (-10,1)... check
// first cut G1 target = lead-in end = (-2,1).
Assert.Equal("G0X-1.5Y1", lines[31]);
Assert.Equal("G1X-2Y1", lines[34]);
Assert.Equal("G1X-12Y1", lines[36]);
}
[Fact]
public void Post_CoordinateFormat_TrimsZerosAndNeverNegativeZero()
{
// Part placed so a coordinate lands on -0.0004 (rounds to -0) and on
// values whose 3-decimal representation keeps trailing digits.
var pgm = new Program(Mode.Absolute);
pgm.Codes.Add(new RapidMove(new Vector(-0.0004, 3.5009)));
pgm.Codes.Add(new LinearMove(new Vector(5, 3.5009)) { Layer = LayerType.Leadin });
pgm.Codes.Add(new LinearMove(new Vector(5, 4)) { Layer = LayerType.Cut });
pgm.Codes.Add(new LinearMove(new Vector(-0.0004, 4)) { Layer = LayerType.Cut });
pgm.Codes.Add(new LinearMove(new Vector(-0.0004, 3.5009)) { Layer = LayerType.Cut });
var drawing = new Drawing("fmt", pgm);
var plate = new Plate(60, 120);
plate.Parts.Add(new Part(drawing, Vector.Zero));
var nest = new Nest("fmt") { Thickness = 0.06, Material = new Material("Mild Steel") };
nest.Plates.Add(plate);
var output = Post(nest);
Assert.Contains("G0X0Y3.501", output); // -0.0004 -> "0" not "-0"; 3.5009 -> 3.501
Assert.Contains("G1X5Y3.501", output);
Assert.DoesNotContain("-0Y", output);
Assert.DoesNotContain("-0\r", output);
}
[Fact]
public void Post_RejectsArcLeadIn_PerTf5200LinearFirstRule()
{
// A contour whose first move after the comp selection is an arc must
// be rejected (TF5200 13.2.4.1: first motion block after selection
// must be LINEAR).
var pgm = new Program(Mode.Absolute);
pgm.Codes.Add(new RapidMove(new Vector(0, 0)));
pgm.Codes.Add(new ArcMove(0, 0, 0, 0.5, RotationType.CCW) { Layer = LayerType.Cut });
var drawing = new Drawing("arcdirect", pgm);
var plate = new Plate(60, 120);
plate.Parts.Add(new Part(drawing, Vector.Zero));
var nest = new Nest("arc") { Thickness = 0.06, Material = new Material("Mild Steel") };
nest.Plates.Add(plate);
var ex = Assert.Throws<InvalidOperationException>(() => Post(nest));
Assert.Contains("13.2.4.1", ex.Message);
}
[Fact]
public void Post_CutOff_PostsWithoutCompensationOrLeadIn()
{
var nest = MakeSquareWithHoleNest();
var plate = nest.Plates[0];
// Vertical cut-off at X=20: a bare rapid + line, no lead-in.
var cutOff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
cutOff.Regenerate(plate, new CutOffSettings());
plate.Parts.Add(new Part(cutOff.Drawing, Vector.Zero));
var lines = Lines(Post(nest));
var n3 = lines.IndexOf("N3:");
Assert.True(n3 > lines.IndexOf("( PART:CutOff-V-20.00 )"));
Assert.Equal("/L \"L0\"", lines[n3 + 1]);
Assert.Equal("V.E.R3=3", lines[n3 + 2]);
Assert.StartsWith("G0X20Y", lines[n3 + 3]);
Assert.Equal("/L \"L4\"", lines[n3 + 4]);
Assert.Equal("/L \"L6\"", lines[n3 + 5]);
Assert.StartsWith("G1X20Y", lines[n3 + 6]);
Assert.Equal("/L \"ZHSOFF\"", lines[n3 + 7]);
}
[Fact]
public void Post_CutOff_FollowsPlateSequence()
{
// Plate order is the cut sequence: part, cut-off, part. The cut-off is
// sequence 2, so it posts second, not after every part.
var nest = MakeSquareWithHoleNest();
var plate = nest.Plates[0];
plate.Parts.Add(new Part(plate.Parts[0].BaseDrawing, new Vector(40, 2)));
var cutOff = new CutOff(new Vector(20, 0), CutOffAxis.Vertical);
cutOff.Regenerate(plate, new CutOffSettings());
plate.Parts.Insert(1, new Part(cutOff.Drawing, Vector.Zero));
var lines = Lines(Post(nest));
Assert.Equal(
new[] { "( PART:square-hole )", "( PART:CutOff-V-20.00 )", "( PART:square-hole )" },
lines.Where(l => l.StartsWith("( PART:")).ToArray()
);
// Part number and restart label run in the same order: the first
// part's hole and perimeter are N1/N2, so the cut-off is N3.
var cutOffBlock = lines.IndexOf("( PART:CutOff-V-20.00 )");
Assert.Equal("( Part #2 )", lines[cutOffBlock - 1]);
Assert.Equal("V.E.R4=2", lines[cutOffBlock + 1]);
Assert.Equal("N3:", lines[cutOffBlock + 2]);
}
[Fact]
public void Post_ValidatesTableSize()
{
var nest = MakeSquareWithHoleNest();
var tight = MakeConfig();
tight.MaxTableX = 100; // plate length is 120
Assert.Contains("exceeds maximum table X", Assert.Throws<InvalidOperationException>(() => Post(nest, tight)).Message);
var wide = MakeConfig();
wide.MaxTableX = 160.25;
wide.MaxTableY = 81.25;
Post(nest, wide); // does not throw
}
[Fact]
public void Post_SkipsSuppressedAndScribeMoves()
{
var pgm = new Program(Mode.Absolute);
// Suppressed contour (every move suppressed, as suppression marks a
// whole feature) must not appear.
pgm.Codes.Add(new RapidMove(new Vector(30, 30)) { Suppressed = true });
pgm.Codes.Add(
new LinearMove(new Vector(31, 30)) { Layer = LayerType.Leadin, Suppressed = true }
);
pgm.Codes.Add(
new LinearMove(new Vector(31, 31)) { Layer = LayerType.Cut, Suppressed = true }
);
// Scribe contour skipped by default.
pgm.Codes.Add(new RapidMove(new Vector(40, 40)));
pgm.Codes.Add(new LinearMove(new Vector(41, 40)) { Layer = LayerType.Leadin });
pgm.Codes.Add(new LinearMove(new Vector(41, 41)) { Layer = LayerType.Scribe });
// Cutting contour that must appear.
pgm.Codes.Add(new RapidMove(new Vector(50, 50)));
pgm.Codes.Add(new LinearMove(new Vector(51, 50)) { Layer = LayerType.Leadin });
pgm.Codes.Add(new LinearMove(new Vector(51, 51)) { Layer = LayerType.Cut });
pgm.Codes.Add(new LinearMove(new Vector(50, 51)) { Layer = LayerType.Cut });
pgm.Codes.Add(new LinearMove(new Vector(50, 50)) { Layer = LayerType.Cut });
var drawing = new Drawing("layers", pgm);
var plate = new Plate(60, 120);
plate.Parts.Add(new Part(drawing, Vector.Zero));
var nest = new Nest("layers") { Thickness = 0.06, Material = new Material("Mild Steel") };
nest.Plates.Add(plate);
var output = Post(nest);
var lines = Lines(output);
// Exactly one contour survived (N0: is the restart-jump label).
Assert.Single(lines.Where(l => l.StartsWith("N") && l != "N0:" && l.EndsWith(":")));
Assert.DoesNotContain("X30Y30", output);
Assert.DoesNotContain("X40Y40", output);
Assert.Contains("G0X50Y50", output);
Assert.Contains("V.E.R3=1", output);
}
[Fact]
public void MaterialCodes_MatchIgnoringCase_AfterJsonReload()
{
var options = new System.Text.Json.JsonSerializerOptions();
var json = System.Text.Json.JsonSerializer.Serialize(
new CIFiberPostConfig
{
MaterialCodes = new() { ["Stainless"] = "SSN" },
DefaultMaterialCode = "MSN",
},
options
);
var config = System.Text.Json.JsonSerializer.Deserialize<CIFiberPostConfig>(json, options)!;
Assert.Equal("SSN", config.ResolveMaterialCode("stainless"));
Assert.Equal("SSN", config.ResolveMaterialCode(" STAINLESS "));
Assert.Equal("MSN", config.ResolveMaterialCode("Aluminum"));
}
[Fact]
public void SavedConfigWithRetiredPartComment_StillLoads()
{
const string json = """
{ "PartComment": "OLD", "MaxTableX": 120.0, "DefaultMaterialCode": "SSN" }
""";
var config = System.Text.Json.JsonSerializer.Deserialize<CIFiberPostConfig>(json)!;
Assert.Equal(120.0, config.MaxTableX);
Assert.Equal("SSN", config.DefaultMaterialCode);
}
private static Nest MakeMultiSheetNest(params (double Width, double Length)[] extraSheets)
{
var nest = MakeSquareWithHoleNest();
var drawing = nest.Plates[0].Parts[0].BaseDrawing;
foreach (var (width, length) in extraSheets)
{
var plate = new Plate(width, length);
plate.Parts.Add(new Part(drawing, new Vector(3, 4)));
nest.Plates.Add(plate);
}
return nest;
}
private static string NewTempDir()
{
var dir = Path.Combine(Path.GetTempPath(), $"cifiber-{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
return dir;
}
[Fact]
public void Post_OneProgramPerSheet_WritesNumberedFiles_EachAFullProgram()
{
var nest = MakeMultiSheetNest((48, 96));
nest.Plates.Add(new Plate(60, 120)); // empty sheet: not posted
var dir = NewTempDir();
try
{
var chosen = Path.Combine(dir, "JOB.cnc");
var post = new CIFiberPostProcessor(MakeConfig());
var expected = post.GetOutputFiles(nest, chosen);
post.Post(nest, chosen);
Assert.Equal(
new[] { Path.Combine(dir, "JOB-1.cnc"), Path.Combine(dir, "JOB-2.cnc") },
expected
);
Assert.Equal(
expected.OrderBy(f => f),
Directory.GetFiles(dir).OrderBy(f => f)
);
var first = Lines(File.ReadAllText(expected[0]));
var second = Lines(File.ReadAllText(expected[1]));
Assert.Contains("V.E.X_SIZE = 120.000", first);
Assert.Contains("V.E.X_SIZE = 96.000", second);
Assert.Contains("V.E.Y_SIZE = 48.000", second);
Assert.Contains("( Sheet number - 1 )", first);
Assert.Contains("( Sheet number - 2 )", second);
foreach (var program in new[] { first, second })
{
// Restart labels start over in every program.
Assert.Contains("N1:", program);
Assert.DoesNotContain("N3:", program);
Assert.Single(program, "M50");
Assert.Equal(
new[] { "L PROGRAMEND.NC", "M50", "M30", "%", "" },
program.TakeLast(5)
);
}
Assert.Contains("G0X3Y3.5", second); // hole pierce at the sheet-2 part location
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public void Post_SingleProgram_ChangesPalletBetweenSheets()
{
var nest = MakeMultiSheetNest((60, 120), (60, 120));
var config = MakeConfig();
config.OneProgramPerSheet = false;
var dir = NewTempDir();
try
{
var chosen = Path.Combine(dir, "JOB.cnc");
var post = new CIFiberPostProcessor(config);
Assert.Equal(new[] { chosen }, post.GetOutputFiles(nest, chosen));
post.Post(nest, chosen);
Assert.Equal(new[] { chosen }, Directory.GetFiles(dir));
var lines = Lines(File.ReadAllText(chosen));
Assert.Equal(3, lines.Count(l => l == "M50"));
Assert.Single(lines, "M30");
var sheet2 = lines.IndexOf("( Sheet number - 2 )");
Assert.Equal(
new[] { "( PART END )", "/L \"L0\"", "M50", "( Sheet number - 2 )" },
lines.Skip(sheet2 - 3).Take(4)
);
Assert.True(lines.IndexOf("( Sheet number - 3 )") > sheet2);
// Restart labels continue across sheets within one program.
Assert.Contains("N6:", lines);
Assert.Contains("V.E.R3=6", lines);
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public void Post_SingleProgram_RejectsMixedSheetSizes_WritingNothing()
{
var nest = MakeMultiSheetNest((48, 96));
var config = MakeConfig();
config.OneProgramPerSheet = false;
var dir = NewTempDir();
try
{
var ex = Assert.Throws<InvalidOperationException>(() =>
new CIFiberPostProcessor(config).Post(nest, Path.Combine(dir, "JOB.cnc"))
);
Assert.Contains("One program per sheet", ex.Message);
Assert.Empty(Directory.GetFiles(dir));
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public void Post_OneProgramPerSheet_OversizeSheet_WritesNoFiles()
{
var nest = MakeMultiSheetNest((60, 200)); // second sheet 200 long > 160.25 table
var dir = NewTempDir();
try
{
var ex = Assert.Throws<InvalidOperationException>(() =>
new CIFiberPostProcessor(MakeConfig()).Post(nest, Path.Combine(dir, "JOB.cnc"))
);
Assert.Contains("exceeds maximum table X", ex.Message);
Assert.Empty(Directory.GetFiles(dir));
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public void Post_OneProgramPerSheet_SingleSheet_UsesChosenName()
{
var nest = MakeSquareWithHoleNest();
var dir = NewTempDir();
try
{
var chosen = Path.Combine(dir, "JOB.cnc");
var post = new CIFiberPostProcessor(MakeConfig());
Assert.Equal(new[] { chosen }, post.GetOutputFiles(nest, chosen));
post.Post(nest, chosen);
Assert.Equal(Post(nest), File.ReadAllText(chosen));
}
finally
{
Directory.Delete(dir, true);
}
}
[Fact]
public void Post_PalletChangeCode_IsConfigurable()
{
var custom = MakeConfig();
custom.PalletChangeCode = " M51 ";
var lines = Lines(Post(MakeSquareWithHoleNest(), custom));
Assert.Equal(new[] { "L PROGRAMEND.NC", "M51", "M30", "%", "" }, lines.TakeLast(5));
Assert.DoesNotContain("M50", lines);
var none = MakeConfig();
none.PalletChangeCode = "";
lines = Lines(Post(MakeSquareWithHoleNest(), none));
Assert.Equal(new[] { "L PROGRAMEND.NC", "M30", "%", "" }, lines.TakeLast(4));
}
[Fact]
public void PostToStream_MultiSheetPerSheetMode_Throws()
{
var nest = MakeMultiSheetNest((60, 120));
var ex = Assert.Throws<InvalidOperationException>(() => Post(nest));
Assert.Contains("file path", ex.Message);
}
[Fact]
public void SavedConfigWithoutSheetSettings_DefaultsToPerSheetAndM50()
{
var config = System.Text.Json.JsonSerializer.Deserialize<CIFiberPostConfig>(
"""{ "MaxTableX": 120.0 }"""
)!;
Assert.True(config.OneProgramPerSheet);
Assert.Equal("M50", config.PalletChangeCode);
}
}