From 82feb78b0fefdffc9ef9306205eeaf44e80e1d8d Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 27 Sep 2026 04:10:22 -0400 Subject: [PATCH] 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. --- .../CIFiberPostProcessorTests.cs | 283 +++++++++++++++++ .../CIFiberSampleRegressionTests.cs | 294 +++++++++++++++++ OpenNest.Tests/OpenNest.Tests.csproj | 1 + OpenNest.sln | 15 + OpenNest/OpenNest.csproj | 1 + .../CIFiberContourExtractor.cs | 168 ++++++++++ .../CIFiberFormatter.cs | 55 ++++ .../CIFiberPostConfig.cs | 123 ++++++++ .../CIFiberPostProcessor.cs | 102 ++++++ .../CIFiberProgramWriter.cs | 296 ++++++++++++++++++ .../CIFiberWinding.cs | 155 +++++++++ .../OpenNest.Posts.CincinnatiCIFiber.csproj | 24 ++ .../OpenNest.Posts.CincinnatiCIFiber.json | 22 ++ README.md | 2 +- 14 files changed, 1540 insertions(+), 1 deletion(-) create mode 100644 OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs create mode 100644 OpenNest.Tests/CincinnatiCIFiber/CIFiberSampleRegressionTests.cs create mode 100644 Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberContourExtractor.cs create mode 100644 Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberFormatter.cs create mode 100644 Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostConfig.cs create mode 100644 Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostProcessor.cs create mode 100644 Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs create mode 100644 Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs create mode 100644 Posts/OpenNest.Posts.CincinnatiCIFiber/OpenNest.Posts.CincinnatiCIFiber.csproj create mode 100644 Posts/OpenNest.Posts.CincinnatiCIFiber/OpenNest.Posts.CincinnatiCIFiber.json diff --git a/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs b/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs new file mode 100644 index 0000000..5ec93e8 --- /dev/null +++ b/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs @@ -0,0 +1,283 @@ +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; + +/// +/// 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. +/// +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()); + } + + /// + /// 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. + /// + 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 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(() => Post(nest)); + Assert.Contains("13.2.4.1", ex.Message); + } + + [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(() => 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); + } +} diff --git a/OpenNest.Tests/CincinnatiCIFiber/CIFiberSampleRegressionTests.cs b/OpenNest.Tests/CincinnatiCIFiber/CIFiberSampleRegressionTests.cs new file mode 100644 index 0000000..a39fb49 --- /dev/null +++ b/OpenNest.Tests/CincinnatiCIFiber/CIFiberSampleRegressionTests.cs @@ -0,0 +1,294 @@ +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; + } +} diff --git a/OpenNest.Tests/OpenNest.Tests.csproj b/OpenNest.Tests/OpenNest.Tests.csproj index ba03b22..d11ff88 100644 --- a/OpenNest.Tests/OpenNest.Tests.csproj +++ b/OpenNest.Tests/OpenNest.Tests.csproj @@ -29,6 +29,7 @@ + diff --git a/OpenNest.sln b/OpenNest.sln index 25e0a69..03bbf68 100644 --- a/OpenNest.sln +++ b/OpenNest.sln @@ -32,6 +32,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.Cincinnati", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.GravographIS", "Posts\OpenNest.Posts.GravographIS\OpenNest.Posts.GravographIS.csproj", "{3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Posts.CincinnatiCIFiber", "Posts\OpenNest.Posts.CincinnatiCIFiber\OpenNest.Posts.CincinnatiCIFiber.csproj", "{26810693-BFFA-4073-B04D-9E1B526935E8}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Data", "OpenNest.Data\OpenNest.Data.csproj", "{A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenNest.Benchmark", "OpenNest.Benchmark\OpenNest.Benchmark.csproj", "{ACD8F725-829A-48A8-AA59-61DD90DE06CA}" @@ -196,6 +198,18 @@ Global {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x64.Build.0 = Release|Any CPU {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.ActiveCfg = Release|Any CPU {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40}.Release|x86.Build.0 = Release|Any CPU + {26810693-BFFA-4073-B04D-9E1B526935E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {26810693-BFFA-4073-B04D-9E1B526935E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {26810693-BFFA-4073-B04D-9E1B526935E8}.Debug|x64.ActiveCfg = Debug|Any CPU + {26810693-BFFA-4073-B04D-9E1B526935E8}.Debug|x64.Build.0 = Debug|Any CPU + {26810693-BFFA-4073-B04D-9E1B526935E8}.Debug|x86.ActiveCfg = Debug|Any CPU + {26810693-BFFA-4073-B04D-9E1B526935E8}.Debug|x86.Build.0 = Debug|Any CPU + {26810693-BFFA-4073-B04D-9E1B526935E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {26810693-BFFA-4073-B04D-9E1B526935E8}.Release|Any CPU.Build.0 = Release|Any CPU + {26810693-BFFA-4073-B04D-9E1B526935E8}.Release|x64.ActiveCfg = Release|Any CPU + {26810693-BFFA-4073-B04D-9E1B526935E8}.Release|x64.Build.0 = Release|Any CPU + {26810693-BFFA-4073-B04D-9E1B526935E8}.Release|x86.ActiveCfg = Release|Any CPU + {26810693-BFFA-4073-B04D-9E1B526935E8}.Release|x86.Build.0 = Release|Any CPU {A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|Any CPU.Build.0 = Debug|Any CPU {A0B4B48E-1DF0-4DD3-B42C-B9B7779EA8B0}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -263,6 +277,7 @@ Global GlobalSection(NestedProjects) = preSolution {FB1B2EB2-9D80-4499-BA93-B4E2F295A532} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E} {3A6B8E7E-9B5F-4D2C-8AE3-2C9F5E3D1A40} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E} + {26810693-BFFA-4073-B04D-9E1B526935E8} = {4052CFAC-1F12-48BE-872D-F503C3B65D7E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {86FE17B3-F764-40AE-BCAA-F26B470CA05C} diff --git a/OpenNest/OpenNest.csproj b/OpenNest/OpenNest.csproj index b93048f..18ceac0 100644 --- a/OpenNest/OpenNest.csproj +++ b/OpenNest/OpenNest.csproj @@ -28,6 +28,7 @@ + diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberContourExtractor.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberContourExtractor.cs new file mode 100644 index 0000000..b931179 --- /dev/null +++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberContourExtractor.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Posts.CincinnatiCIFiber +{ + /// + /// One contour (pierce + lead-in + cut path) reduced to sheet-absolute + /// moves, ready to emit as a TF5200 N-label block. + /// + public sealed class CIFiberContour + { + /// Pierce point (sheet-absolute) — rapid target. + public Vector Pierce { get; set; } + + /// + /// Lead-in move to the contour start (sheet-absolute endpoint), or + /// null when the contour begins cutting immediately at the pierce. + /// Per TF5200 §13.2.4.1 the first motion block after G41/G42 selection + /// must be LINEAR, so callers must reject contours whose lead-in is an + /// arc unless the config tolerates it. + /// + public Motion LeadIn { get; set; } + + /// + /// Additional lead-in layer moves after the first (e.g. the arc that + /// follows CleanHoleLeadIn's line). Emitted before the cut layer turns + /// on, like the sample's lead block. + /// + public List LeadInExtra { get; } = new(); + + /// Cutting moves (sheet-absolute) executed after the cut layer turns on. + public List Cuts { get; } = new(); + + /// True when the contour is an external perimeter (exterior). + public bool IsExterior { get; set; } + } + + /// + /// Extracts cut contours for one part in sheet coordinates. + /// Circular holes arrive as s in an incremental + /// hole-local program; they are flattened here to sheet-absolute moves the + /// same way walks them for HPGL output. + /// Moves with are skipped. + /// + public static class CIFiberContourExtractor + { + public static List Extract(Part part) + { + if (part == null) + throw new ArgumentNullException(nameof(part)); + + var contours = new List(); + Flatten(part.Program, part.Location, Vector.Zero, contours); + return contours; + } + + /// + /// Walk one program whose coordinates are relative to (partLocation + + /// callOffset). Sub-program calls recurse with the extra offset. + /// A contour starts at each rapid move; moves before the first rapid + /// form a contour whose pierce is the last move's endpoint (closed-loop + /// continuation), matching FeatureUtils' synthetic-rapid convention. + /// + private static void Flatten( + Program program, + Vector partLocation, + Vector subOffset, + List contours + ) + { + if (program == null) + return; + + var origin = partLocation + subOffset; + + // Work on a clone when conversion is needed so caller programs are + // never mutated (the CI sample inlines absolute moves). + var codes = program.Codes; + if (program.Mode == Mode.Incremental) + { + var clone = (Program)program.Clone(); + clone.Mode = Mode.Absolute; + codes = clone.Codes; + } + + CIFiberContour current = null; + + void Flush() + { + if (current != null && (current.LeadIn != null || current.Cuts.Count > 0)) + contours.Add(current); + current = null; + } + + foreach (var code in codes) + { + if (code is Motion suppressed && suppressed.Suppressed) + continue; + + switch (code) + { + case RapidMove rapid: + Flush(); + current = new CIFiberContour { Pierce = rapid.EndPoint + origin }; + break; + + case SubProgramCall call: + // A hole call is its own contour: flatten into a temp + // list, then merge as one contour anchored at the call + // offset. + Flush(); + var sub = new List(); + Flatten(call.Program, partLocation, subOffset + call.Offset, sub); + contours.AddRange(sub); + break; + + case Motion motion: + if (current == null) + { + // Contour with no explicit rapid (program starts + // mid-contour): in absolute mode the implicit + // start position is the frame origin, which is the + // closure point of a closed contour = the pierce. + current = new CIFiberContour { Pierce = origin }; + } + + var absolute = (Motion)motion.Clone(); + absolute.Offset(origin); + + var layer = LayerOf(motion); + if (layer == LayerType.Leadin) + { + // First lead-in move opens the lead block; further + // lead-in moves (CleanHole line+arc) extend it. + if (current.LeadIn == null) + current.LeadIn = absolute; + else + current.LeadInExtra.Add(absolute); + } + else + { + // Cut, Leadout and Display all cut: OpenNest tags + // contour moves Display by default (see + // ContourCuttingStrategy.ConvertShapeToMoves), + // only Scribe is reliably a mark. Scribe contours + // are dropped by the writer when SkipScribe is on. + current.Cuts.Add(absolute); + } + break; + } + } + + Flush(); + } + + private static LayerType LayerOf(Motion motion) + { + return motion switch + { + LinearMove l => l.Layer, + ArcMove a => a.Layer, + _ => LayerType.Cut, + }; + } + } +} diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberFormatter.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberFormatter.cs new file mode 100644 index 0000000..46c728f --- /dev/null +++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberFormatter.cs @@ -0,0 +1,55 @@ +using System; +using System.Globalization; + +namespace OpenNest.Posts.CincinnatiCIFiber +{ + /// + /// Formats numeric values for the CI Fiber machine program. + /// Coordinates: up to decimals with trailing + /// zeros trimmed ("3.706", "5.01", "0"), never "-0" (sample NC format). + /// Header values (V.E.*) use fixed decimals. + /// + public sealed class CIFiberFormatter + { + private readonly int _accuracy; + private readonly string _trimFormat; + private readonly string _fixedFormat; + + public CIFiberFormatter(int accuracy) + { + _accuracy = System.Math.Max(0, accuracy); + _trimFormat = _accuracy == 0 ? "0" : "0." + new string('#', _accuracy); + _fixedFormat = _accuracy == 0 ? "0" : "0." + new string('0', _accuracy); + } + + /// + /// Variable-trim coordinate/number format: rounded to the accuracy, + /// trailing zeros removed, "0" for zero, never "-0". + /// + public string Coord(double value) + { + var rounded = System.Math.Round(value, _accuracy, MidpointRounding.AwayFromZero); + if (rounded == 0.0) + rounded = 0.0; // collapse -0 + + return rounded.ToString(_trimFormat, CultureInfo.InvariantCulture); + } + + /// Fixed-decimal format for the V.E.* header variables. + public string Fixed(double value) + { + var rounded = System.Math.Round(value, _accuracy, MidpointRounding.AwayFromZero); + if (rounded == 0.0) + rounded = 0.0; + + return rounded.ToString(_fixedFormat, CultureInfo.InvariantCulture); + } + + /// CRLF line writing helper matching the machine sample exactly. + public static void Line(System.IO.TextWriter w, string text) + { + w.Write(text); + w.Write("\r\n"); + } + } +} diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostConfig.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostConfig.cs new file mode 100644 index 0000000..5a8d4b4 --- /dev/null +++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostConfig.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; + +namespace OpenNest.Posts.CincinnatiCIFiber +{ + /// + /// Configuration for the Cincinnati CI Fiber (TF5200 / nLight CLX) post-processor. + /// All layer macro names, header variable names and the material code map are + /// editable so the post can serve other CI Fiber tables, not only the 4020. + /// + public class CIFiberPostConfig + { + /// Emitted in the "( CONFIGURATION - ... )" header comment. + [DisplayName("Configuration name")] + public string ConfigurationName { get; set; } = "CI FIBER 8K"; + + /// + /// Decimal places for posted coordinates and header values. The sample + /// machine program posts 3 decimals for inch units. + /// + [DisplayName("Posted accuracy")] + public int PostedAccuracy { get; set; } = 3; + + /// Value for V.E.UNIT when the nest is in inches. + [DisplayName("Unit code (inches)")] + public int InchUnitCode { get; set; } = 1; + + /// + /// Value for V.E.UNIT when the nest is in millimeters. The TF5200 + /// metric unit code is unconfirmed; adjust if the controller rejects 0. + /// + [DisplayName("Unit code (millimeters)")] + public int MetricUnitCode { get; set; } = 0; + + /// Emit the V.E.SHEET_WEIGHT header variable (value 0). + [DisplayName("Emit sheet weight")] + public bool EmitSheetWeight { get; set; } = false; + + /// Skip contours whose moves are all LayerType.Scribe (marks). + [DisplayName("Skip scribe layer")] + public bool SkipScribe { get; set; } = true; + + /// Maximum plate length (X) this configuration drives; 0 disables the check. + [DisplayName("Maximum table X")] + public double MaxTableX { get; set; } = 160.25; + + /// Maximum plate width (Y) this configuration drives; 0 disables the check. + [DisplayName("Maximum table Y")] + public double MaxTableY { get; set; } = 81.25; + + /// Comment text after the "( PART:" prefix for each part. + [DisplayName("Part comment")] + public string PartComment { get; set; } = ""; + + /// Skippable global subroutine used to cancel comp / park between features. + [DisplayName("Layer: cancel (L0)")] + public string LayerCancel { get; set; } = "L0"; + + /// Skippable global subroutine for interior lead-in plus G41. + [DisplayName("Layer: interior leadin (L2)")] + public string LayerInteriorLeadin { get; set; } = "L2"; + + /// Skippable global subroutine for exterior lead-in plus G42. + [DisplayName("Layer: exterior leadin (L4)")] + public string LayerExteriorLeadin { get; set; } = "L4"; + + /// Skippable global subroutine that switches the cut layer on. + [DisplayName("Layer: cut on (L6)")] + public string LayerCut { get; set; } = "L6"; + + /// Skippable global subroutine at the end of each contour (head separation / comp off). + [DisplayName("Layer: cut end (ZHSOFF)")] + public string LayerCutEnd { get; set; } = "ZHSOFF"; + + /// External program called once at program start. + [DisplayName("Start macro")] + public string ProgramStartMacro { get; set; } = "PROGRAMSTART.NC"; + + /// External program called after the last sheet. + [DisplayName("End macro")] + public string ProgramEndMacro { get; set; } = "PROGRAMEND.NC"; + + /// Material name (case-insensitive) to machine material code map for V.E.MATERIAL. + [DisplayName("Material codes")] + public Dictionary MaterialCodes { get; set; } = + new(StringComparer.OrdinalIgnoreCase) { ["Mild Steel"] = "MSN" }; + + /// Fallback V.E.MATERIAL code when the material name has no mapping. + [DisplayName("Default material code")] + public string DefaultMaterialCode { get; set; } = "MSN"; + + public string ResolveMaterialCode(string materialName) + { + if (!string.IsNullOrWhiteSpace(materialName) + && MaterialCodes != null + && MaterialCodes.TryGetValue(materialName.Trim(), out var code) + && !string.IsNullOrWhiteSpace(code)) + return code; + + return DefaultMaterialCode ?? "MSN"; + } + + /// + /// Throws if the plate exceeds the configured table envelope. + /// Zero limits disable the check. + /// + public void ValidateTableSize(double lengthX, double widthY) + { + if (MaxTableX > 0 && lengthX > MaxTableX + ToleranceEpsilon) + throw new InvalidOperationException( + $"Plate length {lengthX:0.###} exceeds maximum table X {MaxTableX:0.###}." + ); + + if (MaxTableY > 0 && widthY > MaxTableY + ToleranceEpsilon) + throw new InvalidOperationException( + $"Plate width {widthY:0.###} exceeds maximum table Y {MaxTableY:0.###}." + ); + } + + private const double ToleranceEpsilon = 1e-6; + } +} diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostProcessor.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostProcessor.cs new file mode 100644 index 0000000..99e4d28 --- /dev/null +++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberPostProcessor.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenNest.CNC; + +namespace OpenNest.Posts.CincinnatiCIFiber +{ + /// + /// Post-processor for the CI Fiber family (e.g. the CI Fiber 4020 8kW with + /// nLight CLX / Beckhoff TF5200 / Precitec ProCutter). Named by machine + /// family, not table size: the same post drives other CI Fiber tables via + /// the configured table envelope. Emits the TF5200 skippable-macro program + /// structure of the machine sample (see ). + /// + public sealed class CIFiberPostProcessor : IConfigurablePostProcessor + { + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter() }, + }; + + public string Name => "Cincinnati CI Fiber"; + public string Author => "OpenNest"; + public string Description => + "CI Fiber family laser (TF5200 / nLight CLX), e.g. CI Fiber 4020 8kW"; + + public CIFiberPostConfig Config { get; } + + object IConfigurablePostProcessor.Config => Config; + + public CIFiberPostProcessor() + { + var configPath = GetConfigPath(); + if (File.Exists(configPath)) + { + var json = File.ReadAllText(configPath); + Config = + JsonSerializer.Deserialize(json, JsonOptions) + ?? new CIFiberPostConfig(); + } + else + { + Config = new CIFiberPostConfig(); + SaveConfig(); + } + } + + public CIFiberPostProcessor(CIFiberPostConfig config) + { + Config = config ?? throw new ArgumentNullException(nameof(config)); + } + + public void SaveConfig() + { + var configPath = GetConfigPath(); + var json = JsonSerializer.Serialize(Config, JsonOptions); + File.WriteAllText(configPath, json); + } + + private static string GetConfigPath() + { + var assemblyPath = typeof(CIFiberPostProcessor).Assembly.Location; + var dir = Path.GetDirectoryName(assemblyPath); + var name = Path.GetFileNameWithoutExtension(assemblyPath); + return Path.Combine(dir, name + ".json"); + } + + public void Post(Nest nest, Stream outputStream) + { + if (nest == null) + throw new ArgumentNullException(nameof(nest)); + if (outputStream == null) + throw new ArgumentNullException(nameof(outputStream)); + + // CRLF file, ASCII (UTF-8 without BOM), matching the machine sample. + var encoding = new UTF8Encoding(false); + using var writer = new StreamWriter( + outputStream, + encoding, + 1024, + leaveOpen: true + ); + new CIFiberProgramWriter(Config).Write(nest, writer); + writer.Flush(); + } + + public void Post(Nest nest, string outputFile) + { + using var fs = new FileStream( + outputFile, + FileMode.Create, + FileAccess.Write + ); + Post(nest, fs); + } + } +} diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs new file mode 100644 index 0000000..42c974d --- /dev/null +++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using OpenNest; +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Posts.CincinnatiCIFiber +{ + /// + /// Emits the CI Fiber (TF5200) machine program: header, restart jump, + /// per-sheet / per-part / per-contour blocks and the tail, matching the + /// structure of the Cincinnati-supplied sample NC: + /// + /// + /// ( <nest name> ) + /// ( CONFIGURATION - CI FIBER 8K ) + /// ( August 20, 2026 01:10 PM ) + /// ( Material = Mild Steel .060 ) + /// V.E.MATERIAL = "MSN" ... V.E.UNIT = 1 + /// G90 + /// L PROGRAMSTART.NC + /// P3=V.E.R3 + /// $GOTO NP3: + /// N0: + /// ( Sheet number - 1 ) + /// ( Part #k ) ( PART:... ) V.E.R4=k + /// N<n>: /L "L0" V.E.R3=<n> G0X..Y.. + /// /L "L2" + G41 | /L "L4" + G42 + /// G1X..Y.. (linear lead-in) + /// /L "L6" (cut layer on) + /// G1/G2/G3 ... + /// /L "ZHSOFF" + /// ( PART END ) + /// /L "L0" + /// L PROGRAMEND.NC + /// M50 + /// M30 + /// % + /// + /// + /// Coordinates are spaceless (G1X3.706Y47.488); arcs post I/J incremental + /// from the arc start (TF5200 G162 default). The first motion block after + /// the G41/G42 selection is always LINEAR (TF5200 §13.2.4.1): contours + /// without a lead-in move are rejected with a clear error. + /// + public sealed class CIFiberProgramWriter + { + private readonly CIFiberPostConfig _config; + private readonly CIFiberFormatter _fmt; + + public CIFiberProgramWriter(CIFiberPostConfig config) + { + _config = config ?? throw new ArgumentNullException(nameof(config)); + _fmt = new CIFiberFormatter(config.PostedAccuracy); + } + + public void Write(Nest nest, TextWriter w) + { + if (nest == null) + throw new ArgumentNullException(nameof(nest)); + + var plates = nest.Plates.Where(p => p.Parts.Count > 0).ToList(); + + WriteHeader(nest, w); + + CIFiberFormatter.Line(w, "G90"); + CIFiberFormatter.Line(w, $"L {_config.ProgramStartMacro}"); + CIFiberFormatter.Line(w, "P3=V.E.R3"); + CIFiberFormatter.Line(w, "$GOTO NP3:"); + CIFiberFormatter.Line(w, "N0:"); + + var contourNumber = 0; + for (var s = 0; s < plates.Count; s++) + { + var plate = plates[s]; + _config.ValidateTableSize(plate.Size.Length, plate.Size.Width); + + CIFiberFormatter.Line(w, $"( Sheet number - {s + 1} )"); + contourNumber = WriteSheet(plate, w, contourNumber); + } + + WriteTail(w); + } + + private void WriteHeader(Nest nest, TextWriter w) + { + CIFiberFormatter.Line(w, $"( {nest.Name ?? ""} )"); + CIFiberFormatter.Line(w, $"( CONFIGURATION - {_config.ConfigurationName} )"); + CIFiberFormatter.Line( + w, + "( " + + DateTime.Now.ToString("MMMM d, yyyy hh:mm tt", CultureInfo.InvariantCulture) + + " )" + ); + + var materialName = nest.Material?.Name ?? ""; + var thickness = _fmt.Fixed(nest.Thickness); + if (thickness.StartsWith("0")) + thickness = thickness.Substring(1); // sample posts ".060" + CIFiberFormatter.Line(w, $"( Material = {materialName} {thickness} )".TrimEnd()); + + var code = _config.ResolveMaterialCode(materialName); + CIFiberFormatter.Line(w, $"V.E.MATERIAL = \"{code}\""); + CIFiberFormatter.Line(w, $"V.E.THICKNESS = {_fmt.Fixed(nest.Thickness)}"); + + // Multi-plate nests run one sheet after another in this program; + // the header size describes the first sheet (the machine sample + // carries a single sheet). + var firstPlate = nest.Plates.FirstOrDefault(); + var xSize = firstPlate?.Size.Length ?? 0.0; + var ySize = firstPlate?.Size.Width ?? 0.0; + CIFiberFormatter.Line(w, $"V.E.X_SIZE = {_fmt.Fixed(xSize)}"); + CIFiberFormatter.Line(w, $"V.E.Y_SIZE = {_fmt.Fixed(ySize)}"); + + if (_config.EmitSheetWeight) + CIFiberFormatter.Line(w, "V.E.SHEET_WEIGHT = 0"); + + var unit = nest.Units == Units.Millimeters + ? _config.MetricUnitCode + : _config.InchUnitCode; + CIFiberFormatter.Line(w, $"V.E.UNIT = {unit}"); + } + + private void WriteTail(TextWriter w) + { + CIFiberFormatter.Line(w, SkippableLine(_config.LayerCancel)); + CIFiberFormatter.Line(w, $"L {_config.ProgramEndMacro}"); + CIFiberFormatter.Line(w, "M50"); + CIFiberFormatter.Line(w, "M30"); + CIFiberFormatter.Line(w, "%"); + } + + private int WriteSheet(Plate plate, TextWriter w, int contourNumber) + { + var partNumber = 0; + foreach (var part in plate.Parts) + { + partNumber++; + contourNumber = WritePart(part, partNumber, w, contourNumber); + } + + return contourNumber; + } + + private int WritePart(Part part, int partNumber, TextWriter w, int contourNumber) + { + CIFiberFormatter.Line(w, $"( Part #{partNumber} )"); + + var partComment = ResolvePartComment(part); + CIFiberFormatter.Line(w, $"( PART:{partComment} )"); + + CIFiberFormatter.Line(w, $"V.E.R4={partNumber}"); + + var contours = CIFiberContourExtractor.Extract(part); + if (_config.SkipScribe) + contours = contours.Where(c => !IsScribeContour(c)).ToList(); + + foreach (var contour in contours) + { + contourNumber++; + WriteContour(contour, contourNumber, w); + } + + CIFiberFormatter.Line(w, "( PART END )"); + return contourNumber; + } + + private void WriteContour(CIFiberContour contour, int contourNumber, TextWriter w) + { + var isExterior = contour.IsExterior || CIFiberWinding.IsExterior(contour); + + CIFiberFormatter.Line(w, $"N{contourNumber}:"); + CIFiberFormatter.Line(w, SkippableLine(_config.LayerCancel)); + CIFiberFormatter.Line(w, $"V.E.R3={contourNumber}"); + CIFiberFormatter.Line(w, $"G0X{Fmt(contour.Pierce.X)}Y{Fmt(contour.Pierce.Y)}"); + + if (contour.LeadIn == null) + throw new InvalidOperationException( + $"Contour {contourNumber} has no lead-in move. TF5200 13.2.4.1 " + + "requires a LINEAR motion block immediately after G41/G42 " + + "selection; assign lead-ins before posting." + ); + + if (contour.LeadIn is not LinearMove) + throw new InvalidOperationException( + $"Contour {contourNumber} has an arc lead-in. TF5200 13.2.4.1 " + + "requires the first motion block after G41/G42 selection " + + "to be LINEAR." + ); + + CIFiberFormatter.Line( + w, + SkippableLine( + isExterior ? _config.LayerExteriorLeadin : _config.LayerInteriorLeadin + ) + ); + CIFiberFormatter.Line( + w, + isExterior ? "G42" : "G41" + ); + + var prev = contour.LeadIn.EndPoint; + CIFiberFormatter.Line( + w, + $"G1X{Fmt(contour.LeadIn.EndPoint.X)}Y{Fmt(contour.LeadIn.EndPoint.Y)}" + ); + + // Additional lead-layer moves (e.g. CleanHole arc) still run + // before the cut layer turns on. + foreach (var extra in contour.LeadInExtra) + { + CIFiberFormatter.Line(w, FormatMotion(extra, prev)); + prev = extra.EndPoint; + } + + CIFiberFormatter.Line(w, SkippableLine(_config.LayerCut)); + + foreach (var cut in contour.Cuts) + { + CIFiberFormatter.Line(w, FormatMotion(cut, prev)); + prev = cut.EndPoint; + } + + CIFiberFormatter.Line(w, SkippableLine(_config.LayerCutEnd)); + } + + private string ResolvePartComment(Part part) + { + if (!string.IsNullOrWhiteSpace(_config.PartComment)) + return _config.PartComment; + + var name = part.BaseDrawing?.Name ?? ""; + var source = part.BaseDrawing?.Source?.Path; + if (!string.IsNullOrEmpty(source)) + return Path.GetFileNameWithoutExtension(source); + + return name; + } + + private static bool IsScribeContour(CIFiberContour contour) + { + var hasAny = false; + foreach (var cut in contour.Cuts) + { + var layer = LayerOf(cut); + if (layer != LayerType.Scribe) + return false; + hasAny = true; + } + return hasAny; + } + + private static LayerType LayerOf(Motion motion) => + motion switch + { + LinearMove l => l.Layer, + ArcMove a => a.Layer, + _ => LayerType.Cut, + }; + + private string Fmt(double value) => _fmt.Coord(value); + + /// + /// One motion line: G0/G1/G2/G3 with spaceless X/Y, plus I/J for arcs. + /// I/J are incremental from the arc START (TF5200 G162/basic default, + /// verified against the machine sample): center minus arc start point. + /// + private string FormatMotion(Motion motion, Vector arcStart) + { + var prefix = MovePrefix(motion); + var line = $"{prefix}X{Fmt(motion.EndPoint.X)}Y{Fmt(motion.EndPoint.Y)}"; + + if (motion is ArcMove arc) + { + var i = arc.CenterPoint.X - arcStart.X; + var j = arc.CenterPoint.Y - arcStart.Y; + line += $"I{Fmt(i)}J{Fmt(j)}"; + } + + return line; + } + + private static string SkippableLine(string macroName) => $"/L \"{macroName}\""; + + private static string MovePrefix(Motion motion) => + motion switch + { + RapidMove => "G0", + ArcMove a => a.Rotation == RotationType.CW ? "G2" : "G3", + _ => "G1", + }; + } +} diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs new file mode 100644 index 0000000..46448aa --- /dev/null +++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using OpenNest.CNC; +using OpenNest.Geometry; + +namespace OpenNest.Posts.CincinnatiCIFiber +{ + /// + /// Classifies a flattened contour as interior or exterior from the + /// material side of its cut path, so the post picks the G41 (inside, kerf + /// left of travel) vs G42 (outside, kerf right of travel) lead-in macro to + /// keep the kerf on the scrap side, matching the machine sample (holes cut + /// CCW + G41, perimeter cut CCW + G42). + /// + /// OpenNest contours all keep material on the left of travel (see + /// OffsetSide / ContourCuttingStrategy): the perimeter runs clockwise + /// (negative shoelace area) and holes run counter-clockwise (positive). + /// A contour path that is not closed classifies as interior — a defensive + /// default; open paths should not reach the post. + /// + public static class CIFiberWinding + { + /// + /// Signed shoelace area of the contour path (arcs sampled at their + /// arc-mid point). 0 for degenerate paths. + /// + public static double SignedArea(CIFiberContour contour) + { + var pts = SamplePoints(contour); + if (pts.Count < 3) + return 0.0; + + var sum = 0.0; + for (var i = 0; i < pts.Count; i++) + { + var a = pts[i]; + var b = pts[(i + 1) % pts.Count]; + sum += a.X * b.Y - b.X * a.Y; + } + + return sum / 2.0; + } + + /// + /// True when the contour is a closed CW path — an external perimeter + /// under the OpenNest material-left convention. + /// + public static bool IsExterior(CIFiberContour contour) + { + if (!IsClosed(contour)) + return false; + + return SignedArea(contour) < 0.0; + } + + /// True when the cut path returns to its start point. + public static bool IsClosed(CIFiberContour contour, double tolerance = 1e-6) + { + var pts = SamplePoints(contour); + if (pts.Count < 3) + return false; + + return pts[0].DistanceTo(pts[^1]) <= tolerance; + } + + /// + /// Ordered points along the path: lead-in endpoint, then each cut move + /// endpoint, with arc interior samples at <=90-degree steps (so full + /// circles produce a well-formed polygon). Consecutive duplicates are + /// dropped so zero-length lead-ins do not skew the sample. + /// + public static List SamplePoints(CIFiberContour contour) + { + var pts = new List(); + + void Add(Vector v) + { + if (pts.Count == 0 || pts[^1].DistanceTo(v) > 1e-9) + pts.Add(v); + } + + var prev = contour.LeadIn != null ? contour.LeadIn.EndPoint : contour.Pierce; + Add(prev); + + foreach (var cut in contour.Cuts) + { + if (cut is ArcMove arc) + { + foreach (var mid in ArcSamplePoints(prev, arc)) + Add(mid); + } + Add(cut.EndPoint); + prev = cut.EndPoint; + } + + return pts; + } + + /// + /// Interior points on the arc from to the arc + /// endpoint at no more than ~90-degree sweep intervals. A full circle + /// yields four interior points, giving the shoelace a non-degenerate + /// polygon whose sign is the circle's true winding. + /// + public static List ArcSamplePoints(Vector prev, ArcMove arc) + { + var points = new List(); + var center = arc.CenterPoint; + var radius = center.DistanceTo(arc.EndPoint); + if (radius < 1e-12) + return points; + + var a0 = System.Math.Atan2(prev.Y - center.Y, prev.X - center.X); + var a1 = System.Math.Atan2(arc.EndPoint.Y - center.Y, arc.EndPoint.X - center.X); + + double sweep; + if (arc.Rotation == RotationType.CW) + { + sweep = a0 - a1; + if (sweep <= 0) + sweep += 2.0 * System.Math.PI; + } + else + { + sweep = a1 - a0; + if (sweep <= 0) + sweep += 2.0 * System.Math.PI; + } + + // Full circle (start == end) reads as zero sweep above. + if ( + sweep < 1e-9 + && System.Math.Abs(prev.X - arc.EndPoint.X) < 1e-9 + && System.Math.Abs(prev.Y - arc.EndPoint.Y) < 1e-9 + ) + sweep = 2.0 * System.Math.PI; + + var direction = arc.Rotation == RotationType.CW ? -1.0 : 1.0; + var steps = System.Math.Max(1, (int)System.Math.Ceiling(sweep / (System.Math.PI / 2.0))); + + for (var k = 1; k <= steps; k++) + { + var angle = a0 + direction * sweep * k / (steps + 1); + points.Add( + new Vector( + center.X + radius * System.Math.Cos(angle), + center.Y + radius * System.Math.Sin(angle) + ) + ); + } + + return points; + } + } +} diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/OpenNest.Posts.CincinnatiCIFiber.csproj b/Posts/OpenNest.Posts.CincinnatiCIFiber/OpenNest.Posts.CincinnatiCIFiber.csproj new file mode 100644 index 0000000..8752083 --- /dev/null +++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/OpenNest.Posts.CincinnatiCIFiber.csproj @@ -0,0 +1,24 @@ + + + net8.0 + OpenNest.Posts.CincinnatiCIFiber + + + + + + + PreserveNewest + + + + + ..\..\OpenNest\bin\$(Configuration)\net8.0-windows\Posts\ + $(MSBuildProjectDirectory)\OpenNest.Posts.CincinnatiCIFiber.json + $(PostsDir)OpenNest.Posts.CincinnatiCIFiber.json + + + + + + diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/OpenNest.Posts.CincinnatiCIFiber.json b/Posts/OpenNest.Posts.CincinnatiCIFiber/OpenNest.Posts.CincinnatiCIFiber.json new file mode 100644 index 0000000..be27667 --- /dev/null +++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/OpenNest.Posts.CincinnatiCIFiber.json @@ -0,0 +1,22 @@ +{ + "ConfigurationName": "CI FIBER 8K", + "PostedAccuracy": 3, + "InchUnitCode": 1, + "MetricUnitCode": 0, + "EmitSheetWeight": false, + "SkipScribe": true, + "MaxTableX": 160.25, + "MaxTableY": 81.25, + "PartComment": "", + "LayerCancel": "L0", + "LayerInteriorLeadin": "L2", + "LayerExteriorLeadin": "L4", + "LayerCut": "L6", + "LayerCutEnd": "ZHSOFF", + "ProgramStartMacro": "PROGRAMSTART.NC", + "ProgramEndMacro": "PROGRAMEND.NC", + "MaterialCodes": { + "Mild Steel": "MSN" + }, + "DefaultMaterialCode": "MSN" +} diff --git a/README.md b/README.md index a0335ac..db1fc45 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Layouts are validated (bounds, spacing, quantity, rotation, stock match); invali | **OpenNest.Gpu** | GPU-accelerated pair evaluation (ILGPU) | | **OpenNest.Benchmark** | Head-to-head engine comparison | | **OpenNest.Mcp** | MCP server for AI tool integration | -| **Posts/** | Post-processor plugins (Cincinnati CL lasers, Gravograph IS8000) | +| **Posts/** | Post-processor plugins (Cincinnati CL / CI Fiber lasers, Gravograph IS8000) | | **\*.Tests** | Cross-platform suites; WinForms tests are Windows-only | ## Nesting Engines