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.
295 lines
10 KiB
C#
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;
|
|
}
|
|
}
|