Files
OpenNest/OpenNest.Tests/CincinnatiCIFiber/CIFiberSampleRegressionTests.cs
T
aj 82feb78b0f feat(posts): add Cincinnati CI Fiber (TF5200) post-processor
New post-processor plugin OpenNest.Posts.CincinnatiCIFiber for the CI
Fiber laser family (nLight CLX / Beckhoff TF5200 / Precitec ProCutter,
e.g. the CI Fiber 4020 8kW). Named by machine family, not table size.

Emits the machine program contract of the Cincinnati-supplied sample NC
(12992-4SS_NEST.nc): V.E.* header, restart jump, per-part V.E.R4 blocks,
per-contour N labels with V.E.R3, skippable /L macro lines (L0/L2+G41
interior, L4+G42 exterior, L6 cut-on, ZHSOFF cut-end), G162 incremental
arc I/J, trimmed 3-decimal spaceless coordinates, CRLF, M50/M30/%.

Contour classification (interior vs exterior) derives from the material
side of the closed cut path, not hardcoded winding. SubProgramCall holes
are flattened to sheet coordinates (rotation-safe). Arc lead-ins are
rejected per TF5200 13.2.4.1 (first motion block after G41/G42 selection
must be linear). Table envelope (default 160.25 x 81.25 in) validated.

Tests: structure golden on a square-with-hole nest, rotated-hole flatten,
coordinate format, arc-lead-in rejection, table validation, suppressed/
scribe skipping, plus a SkippableFact regression against the real sample
NC (109 parts, 2071 contours, L2=1962, L4=109, perimeter vertices match
within 0.001). Fixtures configure through OpenNest.Tests/test-config.json
and the regression skips when absent.
2026-09-27 04:10:22 -04:00

295 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using OpenNest.CNC;
using OpenNest.CNC.CuttingStrategy;
using OpenNest.Engine;
using OpenNest.Engine.Sequencing;
using OpenNest.Geometry;
using OpenNest.IO;
using OpenNest.Posts.CincinnatiCIFiber;
namespace OpenNest.Tests.CincinnatiCIFiber;
/// <summary>
/// Regression against the real machine program: posts the reconstructed
/// 12992-4SS nest (same part + layout as the sample NC) and compares contour
/// counts, macro counts and perimeter geometry with 12992-4SS_NEST.nc.
///
/// The fixture lives outside the repository (/srv/shared). Configure it in
/// OpenNest.Tests/test-config.json:
/// { "CIFiberSampleNcPath": "/srv/shared/.../12992-4SS_NEST.nc",
/// "CIFiberReconstructedNestPath": "/srv/shared/.../12992-4SS_NEST_reconstructed.nest" }
/// Both must exist for the test to run; otherwise it skips.
/// </summary>
public class CIFiberSampleRegressionTests
{
private const int ExpectedParts = 109;
private const int ExpectedContours = 2071;
private const int ExpectedL2 = 1962;
private const int ExpectedL4 = 109;
private static (string Nc, string Nest)? ResolveFixture()
{
var nc = TestConfig.GetExistingPath("CIFiberSampleNcPath");
var nest = TestConfig.GetExistingPath("CIFiberReconstructedNestPath");
return nc != null && nest != null ? (nc, nest) : null;
}
private static Nest LoadAndLeadIn(string nestPath)
{
var reader = new NestReader(nestPath);
var nest = reader.Read();
var plate = nest.Plates[0];
plate.CuttingParameters = new CuttingParameters
{
// The sample cuts clean holes with a short perpendicular linear
// lead (pierce ~0.079 outside the contour), including the circles
// (ArcCircleLeadIn). TF5200 13.2.4.1 requires a LINEAR first
// motion block after G41/G42, so holes must have linear leads.
InternalLeadIn = new LineLeadIn { Length = 0.075, ApproachAngle = 90 },
ExternalLeadIn = new LineLeadIn { Length = 0.075, ApproachAngle = 90 },
ArcCircleLeadIn = new LineLeadIn { Length = 0.075, ApproachAngle = 90 },
PierceClearance = 0.02,
};
new LeadInAssigner { Sequencer = new LeftSideSequencer() }.Assign(plate);
return nest;
}
private static string PostNest(Nest nest)
{
var post = new CIFiberPostProcessor(
new CIFiberPostConfig { ConfigurationName = "CI FIBER 8K" }
);
using var ms = new MemoryStream();
post.Post(nest, ms);
return Encoding.UTF8.GetString(ms.ToArray());
}
private static Dictionary<string, int> CountMacroLines(string nc, string macro)
{
var needle = $"/L \"{macro}\"";
return new Dictionary<string, int>
{
[macro] = Regex.Matches(nc, Regex.Escape(needle)).Count,
};
}
private static List<(double X, double Y)> ParseG1Points(string nc)
{
var result = new List<(double, double)>();
foreach (Match m in Regex.Matches(nc, @"G1X(-?[\d.]+)Y(-?[\d.]+)"))
{
result.Add(
(
double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture),
double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture)
)
);
}
return result;
}
[SkippableFact]
public void Post_ReconstructedNest_MatchesSampleCounts()
{
var fixture = ResolveFixture();
Skip.If(fixture == null, "CI Fiber fixtures not configured in test-config.json");
var nest = LoadAndLeadIn(fixture.Value.Nest);
var output = PostNest(nest).Replace("\r\n", "\n");
var sample = File.ReadAllText(fixture.Value.Nc).Replace("\r\n", "\n");
// Parts
var partCount = Regex.Matches(output, @"^\( Part #\d+ \)$", RegexOptions.Multiline).Count;
Assert.Equal(ExpectedParts, partCount);
// Contour labels (skip the N0: restart label)
var labels = Regex
.Matches(output, @"^N(\d+):$", RegexOptions.Multiline)
.Select(m => int.Parse(m.Groups[1].Value))
.ToList();
Assert.Equal(
ExpectedContours,
labels.Where(n => n != 0).Count()
);
Assert.Equal(
Enumerable.Range(1, ExpectedContours),
labels.Where(n => n != 0)
);
// Macro counts. Sample: L0 = contours + 1 tail; the posted file uses
// the same one-per-contour + tail pattern.
Assert.Equal(ExpectedContours + 1, CountMacroLines(output, "L0")["L0"]);
Assert.Equal(ExpectedContours, CountMacroLines(output, "L6")["L6"]);
Assert.Equal(ExpectedContours, CountMacroLines(output, "ZHSOFF")["ZHSOFF"]);
// Interior/exterior split matches the sample: 109 exteriors (one
// perimeter per part), the remaining 1962 contours interior.
Assert.Equal(ExpectedL4, Regex.Matches(output, @"^/L ""L4""$", RegexOptions.Multiline).Count);
Assert.Equal(ExpectedL2, Regex.Matches(output, @"^/L ""L2""$", RegexOptions.Multiline).Count);
// Sample cross-check: same counts as the real machine program.
Assert.Equal(
ExpectedContours + 1,
CountMacroLines(sample, "L0")["L0"]
);
Assert.Equal(
ExpectedL2,
CountMacroLines(sample, "L2")["L2"]
);
Assert.Equal(
ExpectedL4,
CountMacroLines(sample, "L4")["L4"]
);
}
[SkippableFact]
public void Post_ReconstructedNest_PerimeterGeometryMatchesSample()
{
var fixture = ResolveFixture();
Skip.If(fixture == null, "CI Fiber fixtures not configured in test-config.json");
var nest = LoadAndLeadIn(fixture.Value.Nest);
var output = PostNest(nest).Replace("\r\n", "\n");
var sample = File.ReadAllText(fixture.Value.Nc).Replace("\r\n", "\n");
// Compare every exterior (L4) contour's CUT endpoints. Each perimeter
// is the same closed loop; the lead-in/contour start point may differ
// from the original CAM's, so per contour drop one occurrence of the
// start point (the lead-in end, which the closed loop re-targets on
// closure) and compare the remaining vertex multisets.
var samplePts = ExtractExteriorContourCutPoints(sample);
var postedPts = ExtractExteriorContourCutPoints(output);
Assert.NotEmpty(samplePts);
Assert.Equal(samplePts.Count, postedPts.Count);
var sampleKeys = MultisetKeys(samplePts);
var postedKeys = MultisetKeys(postedPts);
Assert.Equal(sampleKeys.Count, postedKeys.Count);
foreach (var (key, count) in sampleKeys)
Assert.True(
postedKeys.TryGetValue(key, out var postedCount) && postedCount == count,
$"Vertex {key} x{count} missing from posted output (found {postedKeys.GetValueOrDefault(key)})"
);
}
/// <summary>Round to 0.001 and count occurrences (tolerance-bucketed multiset).</summary>
private static Dictionary<(long, long), int> MultisetKeys(List<(double X, double Y)> pts)
{
var keys = new Dictionary<(long, long), int>();
foreach (var (x, y) in pts)
{
var key = (
(long)System.Math.Round(x * 1000),
(long)System.Math.Round(y * 1000)
);
keys[key] = keys.GetValueOrDefault(key) + 1;
}
return keys;
}
/// <summary>
/// For each contour that used the L4 (exterior) lead-in macro, its cut
/// endpoints (after /L "L6"), minus one occurrence of the contour start
/// point (the lead-in target, which a closed loop re-hits on closure).
/// The result is a rotation-invariant vertex multiset of every perimeter.
/// </summary>
private static List<(double X, double Y)> ExtractExteriorContourCutPoints(string nc)
{
var all = new List<(double, double)>();
var inExterior = false;
var cutOn = false;
List<(double X, double Y)> current = null;
(double X, double Y)? start = null;
void FinishContour()
{
if (current != null && current.Count > 0)
{
var pts = current;
if (start.HasValue)
{
var idx = pts.FindIndex(
p =>
System.Math.Abs(p.X - start.Value.X) < 1e-4
&& System.Math.Abs(p.Y - start.Value.Y) < 1e-4
);
if (idx >= 0)
pts.RemoveAt(idx);
}
all.AddRange(pts);
}
current = null;
start = null;
}
(double, double)? ParsePoint(string line)
{
var m = Regex.Match(line, @"^G[0-3]X(-?[\d.]+)Y(-?[\d.]+)");
if (!m.Success)
return null;
return (
double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture),
double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture)
);
}
foreach (var raw in nc.Split('\n'))
{
var line = raw.TrimEnd('\r');
if (line == "/L \"L4\"")
{
FinishContour();
inExterior = true;
cutOn = false;
current = new List<(double, double)>();
continue;
}
if (line == "/L \"L2\"")
{
FinishContour();
inExterior = false;
cutOn = false;
continue;
}
if (line == "/L \"L6\"")
{
cutOn = inExterior;
continue;
}
if (line == "/L \"ZHSOFF\"")
{
FinishContour();
inExterior = false;
cutOn = false;
continue;
}
if (!inExterior || current == null)
continue;
var pt = ParsePoint(line);
if (pt == null)
continue;
if (cutOn)
current.Add(pt.Value);
else if (start == null)
start = pt; // the linear lead-in move before L6
}
FinishContour();
return all;
}
}