diff --git a/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs b/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs index 3891e256..b6614f11 100644 --- a/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs +++ b/OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs @@ -1,9 +1,14 @@ 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.Posts.CincinnatiCIFiber; @@ -12,7 +17,7 @@ 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 +/// restart jump, N-labelled contour blocks with /L macro lines, G41 /// selection, trimmed coordinate format and the M50/M30/% tail. /// public class CIFiberPostProcessorTests @@ -131,7 +136,8 @@ public class CIFiberPostProcessorTests 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]); + // CW perimeter: the part is right of travel, so the kerf goes left. + Assert.Equal("G41", lines[33]); Assert.Equal("G1X1Y2", lines[34]); Assert.Equal("/L \"L6\"", lines[35]); Assert.Equal("G1X1Y12", lines[36]); @@ -149,6 +155,101 @@ public class CIFiberPostProcessorTests Assert.Equal("%", lines[46]); } + /// + /// End to end through the real cutting strategy, with a drawing wound + /// against OpenNest's convention (perimeter CCW, hole CW, as a drawing built + /// in code may be): the compensation must still keep the product on the side + /// the controller leaves it (G41 right of travel, G42 left), for the + /// perimeter and the hole alike, and each must get its own lead-in macro. + /// The wrong side under- or oversizes the cut by a kerf width. + /// + [Fact] + public void Post_Compensation_KeepsPartOnProductSide_ForPerimeterAndHole() + { + var pgm = new Program(Mode.Absolute); + // Square drawn CCW, the opposite of OpenNest's CW perimeter convention. + pgm.Codes.Add(new RapidMove(new Vector(0, 0))); + pgm.Codes.Add(new LinearMove(new Vector(10, 0))); + pgm.Codes.Add(new LinearMove(new Vector(10, 10))); + pgm.Codes.Add(new LinearMove(new Vector(0, 10))); + pgm.Codes.Add(new LinearMove(new Vector(0, 0))); + // Hole r=1 at (5,5), drawn CW, the opposite of OpenNest's CCW hole convention. + pgm.Codes.Add(new RapidMove(new Vector(6, 5))); + pgm.Codes.Add(new ArcMove(new Vector(6, 5), new Vector(5, 5), RotationType.CW)); + + var location = new Vector(3, 4); + var plate = new Plate(60, 120); + plate.Parts.Add(new Part(new Drawing("rect-hole", pgm), location)); + plate.CuttingParameters = new CuttingParameters + { + InternalLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 }, + ExternalLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 }, + ArcCircleLeadIn = new LineLeadIn { Length = 0.125, ApproachAngle = 90 }, + }; + new LeadInAssigner { Sequencer = new LeftSideSequencer() }.Assign(plate); + + var nest = new Nest("kerf") { Thickness = 0.06, Material = new Material("Mild Steel") }; + nest.Plates.Add(plate); + var lines = Lines(Post(nest)); + + var holeCenter = location + new Vector(5, 5); + bool InMaterial(Vector p) => + p.X > location.X && p.X < location.X + 10 + && p.Y > location.Y && p.Y < location.Y + 10 + && p.DistanceTo(holeCenter) > 1.0; + + var checkedContours = 0; + for (var i = 0; i < lines.Count; i++) + { + if (lines[i] != "G41" && lines[i] != "G42") + continue; + + // The lead-in ends on the contour; the first cut after /L "L6" sets + // the direction of travel there. + var start = ParseXY(lines[i + 1]); + var onPerimeter = start.DistanceTo(holeCenter) > 1.5; + Assert.Equal(onPerimeter ? "/L \"L4\"" : "/L \"L2\"", lines[i - 1]); + Assert.Equal("/L \"L6\"", lines[i + 2]); + var cut = lines[i + 3]; + Vector travel; + if (cut.StartsWith("G1")) + { + travel = ParseXY(cut) - start; + } + else + { + var m = Regex.Match(cut, @"I(-?[\d.]+)J(-?[\d.]+)"); + var radial = new Vector( + -double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture), + -double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture) + ); // centre -> start + travel = cut.StartsWith("G3") + ? new Vector(-radial.Y, radial.X) + : new Vector(radial.Y, -radial.X); + } + + var length = System.Math.Sqrt(travel.X * travel.X + travel.Y * travel.Y); + var right = new Vector(travel.Y / length, -travel.X / length); + if (lines[i] == "G42") + right = new Vector(-right.X, -right.Y); // G42 keeps the product on the left + Assert.True(InMaterial(start + right * 0.01), $"Part is not right of travel at {lines[i + 1]}"); + Assert.False(InMaterial(start - right * 0.01), $"Kerf side is not scrap at {lines[i + 1]}"); + checkedContours++; + } + + Assert.Equal(2, checkedContours); + } + + private static Vector ParseXY(string line) + { + var m = Regex.Match(line, @"X(-?[\d.]+)Y(-?[\d.]+)"); + Assert.True(m.Success, line); + return new Vector( + double.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture), + double.Parse(m.Groups[2].Value, CultureInfo.InvariantCulture) + ); + } + [Fact] public void Post_FlattensRotatedPartHole_ToSheetCoords() { diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs index f35492cc..6855a7df 100644 --- a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs +++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberProgramWriter.cs @@ -29,7 +29,8 @@ namespace OpenNest.Posts.CincinnatiCIFiber /// ... (single-program mode only: /L "L0", pallet change, next sheet) /// ( Part #k ) ( PART:... ) V.E.R4=k /// N<n>: /L "L0" V.E.R3=<n> G0X..Y.. - /// /L "L2" + G41 | /L "L4" + G42 + /// /L "L2" (hole) | /L "L4" (perimeter), then G41 | G42 + /// (whichever puts the kerf in the scrap) /// G1X..Y.. (linear lead-in) /// /L "L6" (cut layer on) /// G1/G2/G3 ... @@ -222,23 +223,22 @@ namespace OpenNest.Posts.CincinnatiCIFiber contours = contours.Where(c => !IsScribeContour(c)).ToList(); var isCutOff = part.BaseDrawing.IsCutOff; - foreach (var contour in contours) + var perimeter = CIFiberWinding.PerimeterIndex(contours); + for (var i = 0; i < contours.Count; i++) { contourNumber++; if (isCutOff) - WriteCutOffContour(contour, contourNumber, w); + WriteCutOffContour(contours[i], contourNumber, w); else - WriteContour(contour, contourNumber, w); + WriteContour(contours[i], contours[i].IsExterior || i == perimeter, contourNumber, w); } CIFiberFormatter.Line(w, "( PART END )"); return contourNumber; } - private void WriteContour(CIFiberContour contour, int contourNumber, TextWriter w) + private void WriteContour(CIFiberContour contour, bool isExterior, 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}"); @@ -264,9 +264,12 @@ namespace OpenNest.Posts.CincinnatiCIFiber isExterior ? _config.LayerExteriorLeadin : _config.LayerInteriorLeadin ) ); + // G41 leaves the product right of travel, G42 left (Cincinnati CNC + // handbook). Pick the side that puts the kerf in the scrap for the + // way this contour actually runs. CIFiberFormatter.Line( w, - isExterior ? "G42" : "G41" + CIFiberWinding.CompensatesLeft(contour, isExterior) ? "G41" : "G42" ); var prev = contour.LeadIn.EndPoint; diff --git a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs index 46448aa5..ef6125ac 100644 --- a/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs +++ b/Posts/OpenNest.Posts.CincinnatiCIFiber/CIFiberWinding.cs @@ -6,17 +6,16 @@ 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). + /// Works out, from a part's flattened contours, which one is the perimeter + /// (L4 lead-in macro, the rest are holes on L2) and which compensation + /// side keeps the kerf in the scrap. /// - /// 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. + /// G41 leaves the product right of travel. Imported drawings are wound + /// perimeter CW and holes CCW (ShapeProfile.NormalizeWinding), which puts + /// the part on the right and gives G41 throughout; a drawing that reaches + /// the post wound the other way (built in code or edited by hand) needs G42. + /// Paths that enclose no area (open break cuts) count as holes with G41, + /// a defensive default. /// public static class CIFiberWinding { @@ -42,15 +41,59 @@ namespace OpenNest.Posts.CincinnatiCIFiber } /// - /// True when the contour is a closed CW path — an external perimeter - /// under the OpenNest material-left convention. + /// Index of the part's perimeter: the contour enclosing the largest + /// bounding box, as ShapeProfile picks it. -1 when no contour encloses + /// any area. /// - public static bool IsExterior(CIFiberContour contour) + public static int PerimeterIndex(IReadOnlyList contours) { - if (!IsClosed(contour)) - return false; + var best = -1; + var bestArea = 0.0; + for (var i = 0; i < contours.Count; i++) + { + if (!EnclosesArea(contours[i])) + continue; - return SignedArea(contour) < 0.0; + var area = BoundingBoxArea(SamplePoints(contours[i])); + if (best < 0 || area > bestArea) + { + best = i; + bestArea = area; + } + } + + return best; + } + + /// + /// True when G41 (product right of travel) keeps the kerf in the scrap: + /// a CW perimeter or a CCW hole. False calls for G42. + /// + public static bool CompensatesLeft(CIFiberContour contour, bool isPerimeter) + { + if (!EnclosesArea(contour)) + return true; + + var clockwise = SignedArea(contour) < 0.0; + return clockwise == isPerimeter; + } + + private static bool EnclosesArea(CIFiberContour contour) => + System.Math.Abs(SignedArea(contour)) > 1e-9; + + private static double BoundingBoxArea(List pts) + { + double minX = double.MaxValue, minY = double.MaxValue; + double maxX = double.MinValue, maxY = double.MinValue; + foreach (var p in pts) + { + minX = System.Math.Min(minX, p.X); + minY = System.Math.Min(minY, p.Y); + maxX = System.Math.Max(maxX, p.X); + maxY = System.Math.Max(maxY, p.Y); + } + + return (maxX - minX) * (maxY - minY); } /// True when the cut path returns to its start point. diff --git a/docs/cincinnati-ci-fiber-post-output.md b/docs/cincinnati-ci-fiber-post-output.md index 6e84bc48..aff3bb86 100644 --- a/docs/cincinnati-ci-fiber-post-output.md +++ b/docs/cincinnati-ci-fiber-post-output.md @@ -6,7 +6,9 @@ manual or a machine-operation guide. For the separate CL-series post, see Controller reference used during development: Beckhoff *TF5200 | TwinCAT 3 CNC Programming manual*, version 1.33, May 19, 2026 -(`TF5200_programming_manual_en.pdf`). Obtain the applicable documentation from +(`TF5200_programming_manual_en.pdf`), and Cincinnati's *CNC Codes Handbook* +(Programming Manual, rev 1.0, January 12, 2023) with LaserNST CI FIBER 8K sample +programs, supplied by Cincinnati on October 19, 2026. Obtain the applicable documentation from Beckhoff and Cincinnati. Vendor PDFs and full-text extracts stay outside source control; redistribution permission has not been established. @@ -21,9 +23,18 @@ uses the Cincinnati machine-sample convention, not a generic TF5200 laser API: - Parts follow plate order. `V.E.R4` identifies the part within its sheet; numbered contour labels and `V.E.R3` count up through the program for restart lookup (they restart at 1 in each per-sheet program). -- Each contour: `/L "L0"`, restart number, rapid to pierce, then interior - `/L "L2"` with `G41` or exterior `/L "L4"` with `G42`; linear lead-in, +- Each contour: `/L "L0"`, restart number, rapid to pierce, then `/L "L2"` + for a hole or `/L "L4"` for the perimeter, `G41` or `G42`, linear lead-in, `/L "L6"`, cutting moves, and `/L "ZHSOFF"`. +- The perimeter is the part's contour with the largest bounding box, as + `ShapeProfile` picks it; the others are holes. Compensation follows the + Cincinnati CNC Codes Handbook (rev 1.0, G-codes): `G41` leaves the product + right of travel and `G42` leaves it left. The post picks whichever puts the + kerf in the scrap for the way each contour runs: imported drawings (perimeter + CW, holes CCW) get `G41` throughout, and a contour wound the other way gets + `G42`. Cincinnati's LaserNST samples cut both perimeter and holes CCW, so they + use `G42` on the perimeter and `G41` on holes, the same kerf sides. Paths that + enclose no area count as holes with `G41`. - Tail: `/L "L0"`, `L PROGRAMEND.NC`, the pallet-change code (`M50`), `M30`, and `%`. - Motion endpoints are sheet-absolute XY. Arc `I`/`J` are offsets from the arc @@ -86,7 +97,8 @@ validated and saved only when OK is pressed. ## Verification [Output-contract tests](../OpenNest.Tests/CincinnatiCIFiber/CIFiberPostProcessorTests.cs) -cover a square with a hole, coordinate transforms/formatting, lead-in rejection, +cover a square with a hole, kerf side for either winding, coordinate +transforms/formatting, lead-in rejection, cut-off sequencing and output, table bounds, suppression/scribe handling, and multi-sheet output in both modes. Run: