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; /// /// 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. /// 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 CountMacroLines(string nc, string macro) { var needle = $"/L \"{macro}\""; return new Dictionary { [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)})" ); } /// Round to 0.001 and count occurrences (tolerance-bucketed multiset). 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; } /// /// 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. /// 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; } }